From 1bc60d29c5a5f91245e2c77f140c8132ebadc1cd Mon Sep 17 00:00:00 2001 From: 3v0k4 Date: Fri, 21 Nov 2025 11:59:03 +0100 Subject: [PATCH 1/5] refactor: simplify concat of paths --- lib/knapsack_pro.rb | 1 - lib/knapsack_pro/adapters/rspec_adapter.rb | 8 ++++ lib/knapsack_pro/test_file_presenter.rb | 4 ++ .../test_files_with_test_cases_composer.rb | 24 ----------- lib/knapsack_pro/test_suite.rb | 2 +- .../adapters/rspec_adapter_spec.rb | 37 +++++++++++++++++ ...est_files_with_test_cases_composer_spec.rb | 41 ------------------- 7 files changed, 50 insertions(+), 67 deletions(-) delete mode 100644 lib/knapsack_pro/test_files_with_test_cases_composer.rb delete mode 100644 spec/knapsack_pro/test_files_with_test_cases_composer_spec.rb diff --git a/lib/knapsack_pro.rb b/lib/knapsack_pro.rb index e8b60882..697844a3 100644 --- a/lib/knapsack_pro.rb +++ b/lib/knapsack_pro.rb @@ -64,7 +64,6 @@ require_relative 'knapsack_pro/build_distribution_fetcher' require_relative 'knapsack_pro/slow_test_file_determiner' require_relative 'knapsack_pro/slow_test_file_finder' -require_relative 'knapsack_pro/test_files_with_test_cases_composer' require_relative 'knapsack_pro/base_allocator_builder' require_relative 'knapsack_pro/regular_allocator_builder' require_relative 'knapsack_pro/queue_allocator_builder' diff --git a/lib/knapsack_pro/adapters/rspec_adapter.rb b/lib/knapsack_pro/adapters/rspec_adapter.rb index 057583af..33151926 100644 --- a/lib/knapsack_pro/adapters/rspec_adapter.rb +++ b/lib/knapsack_pro/adapters/rspec_adapter.rb @@ -86,6 +86,14 @@ def self.id_path?(path) !id.nil? end + def self.concat_paths(tests, id_tests) + paths = KnapsackPro::TestFilePresenter.paths(tests) + id_paths = KnapsackPro::TestFilePresenter.paths(id_tests) + file_paths = id_paths.map { |id_path| parse_file_path(id_path) } + acc = paths + id_paths - file_paths + KnapsackPro::TestFilePresenter.test_files(acc) + end + def self.rails_helper_exists?(test_dir) File.exist?("#{test_dir}/rails_helper.rb") end diff --git a/lib/knapsack_pro/test_file_presenter.rb b/lib/knapsack_pro/test_file_presenter.rb index 3764c011..ae085286 100644 --- a/lib/knapsack_pro/test_file_presenter.rb +++ b/lib/knapsack_pro/test_file_presenter.rb @@ -12,5 +12,9 @@ def self.stringify_paths(test_file_paths) def self.paths(test_files) test_files.map { |t| t['path'] } end + + def self.test_files(paths) + paths.map { |path| { 'path' => path } } + end end end diff --git a/lib/knapsack_pro/test_files_with_test_cases_composer.rb b/lib/knapsack_pro/test_files_with_test_cases_composer.rb deleted file mode 100644 index 1689a724..00000000 --- a/lib/knapsack_pro/test_files_with_test_cases_composer.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module KnapsackPro - class TestFilesWithTestCasesComposer - # Args: - # All 3 arguments have structure: [{ 'path' => 'spec/a_spec.rb', 'time_execution' => 0.0 }] - # time_execution is not always present (but it's not relevant here) - # - # test_files - list of test files that you want to run tests for - # slow_test_files - list of slow test files that should be split by test cases - # test_file_cases - list of paths to test cases (test examples) inside of all slow test files (slow_test_files) - # Return: - # Test files and test cases paths (it excludes test files that should be split by test cases (test examples)) - def self.call(test_files, slow_test_files, test_file_cases) - slow_test_file_paths = KnapsackPro::TestFilePresenter.paths(slow_test_files) - - test_files_without_slow_test_files = test_files.reject do |test_file| - slow_test_file_paths.include?(test_file.fetch('path')) - end - - test_files_without_slow_test_files + test_file_cases - end - end -end diff --git a/lib/knapsack_pro/test_suite.rb b/lib/knapsack_pro/test_suite.rb index d0b8d627..c985421e 100644 --- a/lib/knapsack_pro/test_suite.rb +++ b/lib/knapsack_pro/test_suite.rb @@ -32,7 +32,7 @@ def calculate_test_files test_file_cases = adapter_class.test_file_cases_for(slow_test_files) - fast_files_and_cases_for_slow_tests = KnapsackPro::TestFilesWithTestCasesComposer.call(all_test_files_to_run, slow_test_files, test_file_cases) + fast_files_and_cases_for_slow_tests = adapter_class.concat_paths(all_test_files_to_run, test_file_cases) @result = Result.new(fast_files_and_cases_for_slow_tests, false) end diff --git a/spec/knapsack_pro/adapters/rspec_adapter_spec.rb b/spec/knapsack_pro/adapters/rspec_adapter_spec.rb index bf51ef31..0d9ace18 100644 --- a/spec/knapsack_pro/adapters/rspec_adapter_spec.rb +++ b/spec/knapsack_pro/adapters/rspec_adapter_spec.rb @@ -352,6 +352,43 @@ end end + describe '.concat_paths' do + let(:tests) do + [ + { 'path' => 'spec/a_spec.rb' }, + { 'path' => 'spec/b_spec.rb' }, + { 'path' => 'spec/c_spec.rb' }, + { 'path' => 'spec/slow_1_spec.rb' }, + { 'path' => 'spec/slow_2_spec.rb' }, + ] + end + + let(:id_tests) do + [ + { 'path' => 'spec/slow_1_spec.rb[1:1]' }, + { 'path' => 'spec/slow_1_spec.rb[1:2]' }, + { 'path' => 'spec/slow_2_spec.rb[1:1:1]' }, + { 'path' => 'spec/slow_2_spec.rb[1:1:2]' }, + { 'path' => 'spec/slow_2_spec.rb[1:1:3]' }, + ] + end + + subject { described_class.concat_paths(tests, id_tests) } + + it 'concats by replacing tests with the associated id_tests' do + expect(subject).to eq([ + { 'path' => 'spec/a_spec.rb' }, + { 'path' => 'spec/b_spec.rb' }, + { 'path' => 'spec/c_spec.rb' }, + { 'path' => 'spec/slow_1_spec.rb[1:1]' }, + { 'path' => 'spec/slow_1_spec.rb[1:2]' }, + { 'path' => 'spec/slow_2_spec.rb[1:1:1]' }, + { 'path' => 'spec/slow_2_spec.rb[1:1:2]' }, + { 'path' => 'spec/slow_2_spec.rb[1:1:3]' }, + ]) + end + end + describe 'private .scheduled_paths' do subject { described_class.send(:scheduled_paths) } diff --git a/spec/knapsack_pro/test_files_with_test_cases_composer_spec.rb b/spec/knapsack_pro/test_files_with_test_cases_composer_spec.rb deleted file mode 100644 index 58f18d6a..00000000 --- a/spec/knapsack_pro/test_files_with_test_cases_composer_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -describe KnapsackPro::TestFilesWithTestCasesComposer do - let(:test_files) do - [ - { 'path' => 'spec/a_spec.rb' }, - { 'path' => 'spec/b_spec.rb' }, - { 'path' => 'spec/c_spec.rb' }, - { 'path' => 'spec/slow_1_spec.rb' }, - { 'path' => 'spec/slow_2_spec.rb' }, - ] - end - let(:slow_test_files) do - [ - { 'path' => 'spec/slow_1_spec.rb', 'time_execution' => 1.0 }, - { 'path' => 'spec/slow_2_spec.rb', 'time_execution' => 2.0 }, - ] - end - let(:test_file_cases) do - [ - { 'path' => 'spec/slow_1_spec.rb[1:1]' }, - { 'path' => 'spec/slow_1_spec.rb[1:2]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:1]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:2]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:3]' }, - ] - end - - subject { described_class.call(test_files, slow_test_files, test_file_cases) } - - it 'returns test files that are not slow and test file cases for slow test files' do - expect(subject).to eq([ - { 'path' => 'spec/a_spec.rb' }, - { 'path' => 'spec/b_spec.rb' }, - { 'path' => 'spec/c_spec.rb' }, - { 'path' => 'spec/slow_1_spec.rb[1:1]' }, - { 'path' => 'spec/slow_1_spec.rb[1:2]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:1]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:2]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:3]' }, - ]) - end -end From 00944c660bc08ed72a1b73d3c0879440bde36be9 Mon Sep 17 00:00:00 2001 From: 3v0k4 Date: Fri, 21 Nov 2025 15:20:55 +0100 Subject: [PATCH 2/5] test: improve coverage --- .../integration/runners/queue/rspec_runner.rb | 43 ++++++++++--------- .../runners/queue/rspec_runner_spec.rb | 28 ++++++++++-- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/spec/integration/runners/queue/rspec_runner.rb b/spec/integration/runners/queue/rspec_runner.rb index 1d5f1a38..6cb0f4e8 100644 --- a/spec/integration/runners/queue/rspec_runner.rb +++ b/spec/integration/runners/queue/rspec_runner.rb @@ -1,5 +1,6 @@ require 'knapsack_pro' require 'json' +require 'ostruct' ENV['KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC'] = SecureRandom.hex ENV['KNAPSACK_PRO_CI_NODE_BUILD_ID'] = SecureRandom.uuid @@ -18,7 +19,24 @@ def self.log(message) module KnapsackProExtensions module QueueAllocatorExtension - def test_file_paths(can_initialize_queue, executed_test_files) + # Succeeds to initialize on the first request + def initialize_queue(tests_to_run, batch_uuid) + # Ensure the stubbed batches matches the tests Knapsack Pro wants to run + raise unless tests_to_run.map { _1["path"] }.sort == SPEC_BATCHES.flatten.sort + test__pull + end + + # On the first request it fails, but succeeds on the second request + def pull_tests_from_queue(can_initialize_queue, batch_uuid) + if can_initialize_queue + connection = OpenStruct.new(success?: true, api_code: KnapsackPro::Client::API::V1::Queues::CODE_ATTEMPT_CONNECT_TO_QUEUE_FAILED) + KnapsackPro::QueueAllocator::Batch.new(connection, {}) + else + test__pull + end + end + + def test__pull @batch_index ||= 0 last_batch = [] batches = [*SPEC_BATCHES, last_batch] @@ -29,7 +47,8 @@ def test_file_paths(can_initialize_queue, executed_test_files) IntegrationTestLogger.log("Stubbed tests from the Queue API: #{tests.inspect}") end - tests + connection = OpenStruct.new(success?: true) + KnapsackPro::QueueAllocator::Batch.new(connection, { "test_files" => tests.map { |path| { "path" => path } } }) end end @@ -58,23 +77,7 @@ def test_file_cases_for(slow_test_files) end KnapsackPro::QueueAllocator.prepend(KnapsackProExtensions::QueueAllocatorExtension) - -module KnapsackPro - class Report - class << self - prepend KnapsackProExtensions::Report - end - end -end - -module KnapsackPro - module Adapters - class RSpecAdapter - class << self - prepend KnapsackProExtensions::RSpecAdapter - end - end - end -end +KnapsackPro::Report.singleton_class.prepend(KnapsackProExtensions::Report) +KnapsackPro::Adapters::RSpecAdapter.singleton_class.prepend(KnapsackProExtensions::RSpecAdapter) KnapsackPro::Runners::Queue::RSpecRunner.run(RSPEC_OPTIONS) diff --git a/spec/integration/runners/queue/rspec_runner_spec.rb b/spec/integration/runners/queue/rspec_runner_spec.rb index e4c9f981..274ce07b 100644 --- a/spec/integration/runners/queue/rspec_runner_spec.rb +++ b/spec/integration/runners/queue/rspec_runner_spec.rb @@ -43,6 +43,7 @@ def create_rails_helper_file(rails_helper) File.open(rails_helper_path, 'w') { |file| file.write(rails_helper) } end + # The batches returned by each call to initialize_queue or pull_tests_from_queue def stub_spec_batches(batched_tests) ENV['TEST__SPEC_BATCHES'] = batched_tests.to_json end @@ -89,6 +90,7 @@ def log(stdout, stderr, status) FileUtils.mkdir_p(SPEC_DIRECTORY) ENV['KNAPSACK_PRO_LOG_LEVEL'] = 'debug' + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES'] = 'false' # Useful when creating or editing a test: # ENV['TEST__SHOW_DEBUG_LOG'] = 'true' end @@ -96,6 +98,7 @@ def log(stdout, stderr, status) FileUtils.rm_rf(SPEC_DIRECTORY) FileUtils.mkdir_p(SPEC_DIRECTORY) + ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_LOG_LEVEL') ENV.keys.select { _1.start_with?('TEST__') }.each do |key| ENV.delete(key) @@ -1961,13 +1964,16 @@ def when_first_matching_example_defined(type:) context 'when the RSpec split by test examples is enabled' do before do + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES'] = 'true' # Remember to stub the Queue API batches to include test examples (example: a_spec.rb[1:1]) # for the following slow test files. ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "#{SPEC_DIRECTORY}/a_spec.rb" ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end + after do + ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') ENV.delete('KNAPSACK_PRO_CI_NODE_TOTAL') end @@ -2055,6 +2061,7 @@ def when_first_matching_example_defined(type:) context 'when the RSpec split by test examples is enabled AND --tag is set' do before do + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES'] = 'true' # Remember to stub the Queue API batches to include test examples (example: a_spec.rb[1:2]) # for the following slow test files. ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "#{SPEC_DIRECTORY}/a_spec.rb" @@ -2062,6 +2069,7 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end after do + ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') ENV.delete('KNAPSACK_PRO_CI_NODE_TOTAL') end @@ -2132,6 +2140,7 @@ def when_first_matching_example_defined(type:) let(:json_file) { "#{SPEC_DIRECTORY}/rspec.json" } before do + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES'] = 'true' # Remember to stub the Queue API batches to include test examples (example: a_spec.rb[1:1]) # for the following slow test files. ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "#{SPEC_DIRECTORY}/a_spec.rb" @@ -2139,6 +2148,7 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end after do + ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') ENV.delete('KNAPSACK_PRO_CI_NODE_TOTAL') end @@ -2231,6 +2241,7 @@ def when_first_matching_example_defined(type:) let(:xml_file) { "#{SPEC_DIRECTORY}/rspec.xml" } before do + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES'] = 'true' # Remember to stub the Queue API batches to include test examples (example: a_spec.rb[1:1]) # for the following slow test files. ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "#{SPEC_DIRECTORY}/a_spec.rb" @@ -2238,6 +2249,7 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end after do + ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') ENV.delete('KNAPSACK_PRO_CI_NODE_TOTAL') end @@ -2328,6 +2340,7 @@ def when_first_matching_example_defined(type:) let(:coverage_file) { "#{coverage_dir}/index.html" } before do + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES'] = 'true' # Remember to stub the Queue API batches to include test examples (example: a_spec.rb[1:1]) # for the following slow test files. ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "#{SPEC_DIRECTORY}/a_spec.rb" @@ -2335,6 +2348,7 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end after do + ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') ENV.delete('KNAPSACK_PRO_CI_NODE_TOTAL') end @@ -2415,7 +2429,9 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "" ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end + after do + ENV.delete('KNAPSACK_PRO_TEST_FILE_LIST') ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') ENV.delete('KNAPSACK_PRO_CI_NODE_TOTAL') @@ -2461,15 +2477,19 @@ def when_first_matching_example_defined(type:) generate_specs(spec_helper_with_knapsack, rspec_options, [ [spec_a, spec_b, spec_c] ]) - stub_test_cases_for_slow_test_files([ - "#{spec_a.path}[1:1]", - "#{spec_a.path}[1:2]", - ]) + stub_spec_batches([ ["#{spec_a.path}[1:1]", spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) + ENV['KNAPSACK_PRO_TEST_FILE_LIST'] = [ + "#{spec_a.path}[1:1]", + "#{spec_a.path}[1:2]", + spec_b.path, + spec_c.path + ].join(",") + actual = subject expect(actual.stdout).to include( From 06d710cfb593dfe7d15b00ba14c8d1d06815ec5f Mon Sep 17 00:00:00 2001 From: 3v0k4 Date: Fri, 21 Nov 2025 16:56:57 +0100 Subject: [PATCH 3/5] refactor: merger --- lib/knapsack_pro.rb | 1 - lib/knapsack_pro/slow_test_file_finder.rb | 2 +- .../test_case_mergers/base_merger.rb | 31 ------------ .../test_case_mergers/rspec_merger.rb | 50 +++++++------------ .../slow_test_file_finder_spec.rb | 4 +- .../test_case_mergers/base_merger_spec.rb | 27 ---------- .../test_case_mergers/rspec_merger_spec.rb | 17 ++++++- 7 files changed, 39 insertions(+), 93 deletions(-) delete mode 100644 lib/knapsack_pro/test_case_mergers/base_merger.rb delete mode 100644 spec/knapsack_pro/test_case_mergers/base_merger_spec.rb diff --git a/lib/knapsack_pro.rb b/lib/knapsack_pro.rb index 697844a3..d6084ebc 100644 --- a/lib/knapsack_pro.rb +++ b/lib/knapsack_pro.rb @@ -59,7 +59,6 @@ require_relative 'knapsack_pro/queue_allocator' require_relative 'knapsack_pro/mask_string' require_relative 'knapsack_pro/test_suite' -require_relative 'knapsack_pro/test_case_mergers/base_merger' require_relative 'knapsack_pro/test_case_mergers/rspec_merger' require_relative 'knapsack_pro/build_distribution_fetcher' require_relative 'knapsack_pro/slow_test_file_determiner' diff --git a/lib/knapsack_pro/slow_test_file_finder.rb b/lib/knapsack_pro/slow_test_file_finder.rb index 4dffee4c..cad0ecb0 100644 --- a/lib/knapsack_pro/slow_test_file_finder.rb +++ b/lib/knapsack_pro/slow_test_file_finder.rb @@ -15,7 +15,7 @@ def self.call(adapter_class) build_distribution_entity = KnapsackPro::BuildDistributionFetcher.call test_files_from_api = build_distribution_entity.test_files - merged_test_files_from_api = KnapsackPro::TestCaseMergers::BaseMerger.call(adapter_class, test_files_from_api) + merged_test_files_from_api = KnapsackPro::TestCaseMergers::RSpecMerger.new(test_files_from_api).call test_files_existing_on_disk = KnapsackPro::TestFileFinder.select_test_files_that_can_be_run(adapter_class, merged_test_files_from_api) diff --git a/lib/knapsack_pro/test_case_mergers/base_merger.rb b/lib/knapsack_pro/test_case_mergers/base_merger.rb deleted file mode 100644 index bf86025c..00000000 --- a/lib/knapsack_pro/test_case_mergers/base_merger.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module KnapsackPro - module TestCaseMergers - class BaseMerger - # values must be string to avoid circular dependency problem during loading files - ADAPTER_TO_MERGER_MAP = { - KnapsackPro::Adapters::RSpecAdapter => 'KnapsackPro::TestCaseMergers::RSpecMerger', - } - - def self.call(adapter_class, test_files) - merger_class = - ADAPTER_TO_MERGER_MAP[adapter_class] || - raise("Test case merger does not exist for adapter_class: #{adapter_class}") - Kernel.const_get(merger_class).new(test_files).call - end - - def initialize(test_files) - @test_files = test_files - end - - def call - raise NotImplementedError - end - - private - - attr_reader :test_files - end - end -end diff --git a/lib/knapsack_pro/test_case_mergers/rspec_merger.rb b/lib/knapsack_pro/test_case_mergers/rspec_merger.rb index eea6fd56..ad26522f 100644 --- a/lib/knapsack_pro/test_case_mergers/rspec_merger.rb +++ b/lib/knapsack_pro/test_case_mergers/rspec_merger.rb @@ -2,44 +2,32 @@ module KnapsackPro module TestCaseMergers - class RSpecMerger < BaseMerger + class RSpecMerger + def initialize(test_files) + @test_files = test_files + end + def call - all_test_files_hash = {} - merged_test_file_examples_hash = {} + file_paths = {} + id_paths = {} - test_files.each do |test_file| - path = test_file.fetch('path') - test_file_path = extract_test_file_path(path) + @test_files.each do |test_file| + raw_path = test_file.fetch('path') + path = KnapsackPro::Adapters::RSpecAdapter.parse_file_path(raw_path) - if KnapsackPro::Adapters::RSpecAdapter.id_path?(path) - merged_test_file_examples_hash[test_file_path] ||= 0.0 - merged_test_file_examples_hash[test_file_path] += test_file.fetch('time_execution') + if KnapsackPro::Adapters::RSpecAdapter.id_path?(raw_path) + id_paths[path] ||= 0.0 + id_paths[path] += test_file.fetch('time_execution') else - all_test_files_hash[test_file_path] = test_file.fetch('time_execution') + file_paths[path] = test_file.fetch('time_execution') # may be nil end end - merged_test_file_examples_hash.each do |path, time_execution| - all_test_files_hash[path] = [time_execution, all_test_files_hash[path]].compact.max - end - - merged_test_files = [] - all_test_files_hash.each do |path, time_execution| - merged_test_files << { - 'path' => path, - 'time_execution' => time_execution - } - end - merged_test_files - end - - private - - # path - can be: - # test file path: spec/a_spec.rb - # or test example path: spec/a_spec.rb[1:1] - def extract_test_file_path(path) - path.gsub(/\.rb\[.+\]$/, '.rb') + file_paths + .merge(id_paths) { |_, v1, v2| [v1, v2].compact.max } + .map do |path, time_execution| + { 'path' => path, 'time_execution' => time_execution } + end end end end diff --git a/spec/knapsack_pro/slow_test_file_finder_spec.rb b/spec/knapsack_pro/slow_test_file_finder_spec.rb index 80cdaa59..ffd35ace 100644 --- a/spec/knapsack_pro/slow_test_file_finder_spec.rb +++ b/spec/knapsack_pro/slow_test_file_finder_spec.rb @@ -16,8 +16,10 @@ build_distribution_entity = instance_double(KnapsackPro::BuildDistributionFetcher::BuildDistributionEntity, test_files: test_files_from_api) expect(KnapsackPro::BuildDistributionFetcher).to receive(:call).and_return(build_distribution_entity) + rspec_merger = double merged_test_files_from_api = double - expect(KnapsackPro::TestCaseMergers::BaseMerger).to receive(:call).with(adapter_class, test_files_from_api).and_return(merged_test_files_from_api) + expect(rspec_merger).to receive(:call).and_return(merged_test_files_from_api) + expect(KnapsackPro::TestCaseMergers::RSpecMerger).to receive(:new).with(test_files_from_api).and_return(rspec_merger) test_files_existing_on_disk = double expect(KnapsackPro::TestFileFinder).to receive(:select_test_files_that_can_be_run).with(adapter_class, merged_test_files_from_api).and_return(test_files_existing_on_disk) diff --git a/spec/knapsack_pro/test_case_mergers/base_merger_spec.rb b/spec/knapsack_pro/test_case_mergers/base_merger_spec.rb deleted file mode 100644 index a3cf1c2e..00000000 --- a/spec/knapsack_pro/test_case_mergers/base_merger_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -describe KnapsackPro::TestCaseMergers::BaseMerger do - describe '.call' do - let(:test_files) { double } - - subject { described_class.call(adapter_class, test_files) } - - context 'when adapter_class is KnapsackPro::Adapters::RSpecAdapter' do - let(:adapter_class) { KnapsackPro::Adapters::RSpecAdapter } - - it do - result = double - rspec_merger = instance_double(KnapsackPro::TestCaseMergers::RSpecMerger, call: result) - expect(KnapsackPro::TestCaseMergers::RSpecMerger).to receive(:new).with(test_files).and_return(rspec_merger) - - expect(subject).to eq result - end - end - - context 'when adapter_class is unknown' do - let(:adapter_class) { 'fake-adapter' } - - it do - expect { subject }.to raise_error 'Test case merger does not exist for adapter_class: fake-adapter' - end - end - end -end diff --git a/spec/knapsack_pro/test_case_mergers/rspec_merger_spec.rb b/spec/knapsack_pro/test_case_mergers/rspec_merger_spec.rb index e6ba7bb3..edadc1b8 100644 --- a/spec/knapsack_pro/test_case_mergers/rspec_merger_spec.rb +++ b/spec/knapsack_pro/test_case_mergers/rspec_merger_spec.rb @@ -1,6 +1,6 @@ describe KnapsackPro::TestCaseMergers::RSpecMerger do describe '#call' do - subject { described_class.new(test_files).call } + subject { KnapsackPro::TestCaseMergers::RSpecMerger.new(test_files).call } context 'when test files are regular file paths (not test example paths)' do let(:test_files) do @@ -76,6 +76,21 @@ ]) end end + + context 'with a file path with nil time_execution' do + let(:test_files) do + [ + { 'path' => 'spec/test_case_spec.rb', 'time_execution' => nil }, + { 'path' => 'spec/test_case_spec.rb[1:1]', 'time_execution' => 1.0 }, + ] + end + + it "uses the id path time_execution" do + expect(subject).to eq([ + { 'path' => 'spec/test_case_spec.rb', 'time_execution' => 1.0 }, + ]) + end + end end end end From 93459919a418ac975ac89ca80042feff6b78c3a8 Mon Sep 17 00:00:00 2001 From: 3v0k4 Date: Fri, 21 Nov 2025 16:58:19 +0100 Subject: [PATCH 4/5] refactor: test file finder --- lib/knapsack_pro/test_file_finder.rb | 45 ++++++++-------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/lib/knapsack_pro/test_file_finder.rb b/lib/knapsack_pro/test_file_finder.rb index 2e1de911..53bd7887 100644 --- a/lib/knapsack_pro/test_file_finder.rb +++ b/lib/knapsack_pro/test_file_finder.rb @@ -21,26 +21,13 @@ def self.slow_test_files_by_pattern(adapter_class) slow_test_file_entities & test_file_entities end - # Args: - # test_file_entities_to_run - it can be list of slow test files that you want to run - # Return: - # subset of test_file_entities_to_run that are present on disk and it is subset of tests matching pattern KNAPSACK_PRO_TEST_FILE_PATTERN - # Thanks to that we can select only slow test files that are within list of test file pattern we want to run tests for - def self.select_test_files_that_can_be_run(adapter_class, test_file_entities_to_run) + def self.select_test_files_that_can_be_run(adapter_class, candidate_test_files) test_file_pattern = KnapsackPro::TestFilePattern.call(adapter_class) - test_file_entities = call(test_file_pattern) - - test_file_paths_existing_on_disk = KnapsackPro::TestFilePresenter.paths(test_file_entities) - - selected_test_files = [] - - test_file_entities_to_run.each do |test_file_entity| - if test_file_paths_existing_on_disk.include?(test_file_entity.fetch('path')) - selected_test_files << test_file_entity - end - end - - selected_test_files + scheduled_test_files = call(test_file_pattern) + scheduled_paths = KnapsackPro::TestFilePresenter.paths(scheduled_test_files) + candidate_paths = KnapsackPro::TestFilePresenter.paths(candidate_test_files) + intersection = scheduled_paths & candidate_paths + KnapsackPro::TestFilePresenter.test_files(intersection) end def initialize(test_file_pattern, test_file_list_enabled) @@ -49,18 +36,16 @@ def initialize(test_file_pattern, test_file_list_enabled) end def call - test_file_hashes = [] - test_files.each do |test_file_path| - test_file_hashes << test_file_hash_for(test_file_path) + file_paths.map do |file_path| + { 'path' => TestFileCleaner.clean(file_path) } end - test_file_hashes end private attr_reader :test_file_pattern, :test_file_list_enabled - def test_files + def file_paths if test_file_list_enabled && KnapsackPro::Config::Env.test_file_list return KnapsackPro::Config::Env.test_file_list.split(',').map(&:strip) end @@ -69,22 +54,16 @@ def test_files return File.read(KnapsackPro::Config::Env.test_file_list_source_file).split(/\n/) end - test_file_paths = Dir.glob(test_file_pattern).uniq + included_paths = Dir.glob(test_file_pattern).uniq - excluded_test_file_paths = + excluded_paths = if KnapsackPro::Config::Env.test_file_exclude_pattern Dir.glob(KnapsackPro::Config::Env.test_file_exclude_pattern).uniq else [] end - (test_file_paths - excluded_test_file_paths).sort - end - - def test_file_hash_for(test_file_path) - { - 'path' => TestFileCleaner.clean(test_file_path) - } + (included_paths - excluded_paths).sort end end end From 9bf6bd408359b6c2fb9c8745c85f8fc8a2ae1c81 Mon Sep 17 00:00:00 2001 From: 3v0k4 Date: Fri, 21 Nov 2025 17:10:04 +0100 Subject: [PATCH 5/5] feat: allow to precalculate split by test examples --- .circleci/config.yml | 6 +- CHANGELOG.md | 1 + lib/knapsack_pro/adapters/base_adapter.rb | 18 +-- lib/knapsack_pro/adapters/rspec_adapter.rb | 18 +-- .../build_distribution_fetcher.rb | 24 ++-- .../client/api/v1/build_distributions.rb | 9 +- lib/knapsack_pro/config/env.rb | 16 +-- lib/knapsack_pro/slow_test_file_determiner.rb | 22 ---- lib/knapsack_pro/slow_test_file_finder.rb | 10 +- .../rspec_test_example_detector.rb | 123 +++++++----------- .../test_case_mergers/rspec_merger.rb | 12 +- lib/knapsack_pro/test_file_finder.rb | 16 +-- lib/knapsack_pro/test_suite.rb | 38 ++---- lib/knapsack_pro/urls.rb | 2 + lib/tasks/rspec.rake | 31 ++++- .../integration/runners/queue/rspec_runner.rb | 17 +-- .../runners/queue/rspec_runner_spec.rb | 42 +++--- .../adapters/base_adapter_spec.rb | 40 +----- .../adapters/rspec_adapter_spec.rb | 44 +++---- .../build_distribution_fetcher_spec.rb | 3 +- .../client/api/v1/build_distributions_spec.rb | 31 +---- .../slow_test_file_determiner_spec.rb | 46 ------- .../slow_test_file_finder_spec.rb | 2 - .../rspec_test_example_detector_spec.rb | 65 ++++----- spec/knapsack_pro/test_file_finder_spec.rb | 10 +- 25 files changed, 220 insertions(+), 426 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 545da68c..0035c18b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -443,19 +443,19 @@ workflows: matrix: parameters: ruby: ["3.2", "3.3", "3.4"] - rspec: ["3.12.3", "3.13.3"] + rspec: ["3.12.3", "3.13.6"] - e2e-regular-rspec: name: e2e-regular__ruby-<< matrix.ruby >>__rspec-<< matrix.rspec >> matrix: parameters: ruby: ["3.2", "3.3", "3.4"] - rspec: ["3.12.3", "3.13.3"] + rspec: ["3.12.3", "3.13.6"] - e2e-queue-rspec: name: e2e-queue__ruby-<< matrix.ruby >>__rspec-<< matrix.rspec >> matrix: parameters: ruby: ["3.2", "3.3", "3.4"] - rspec: ["3.12.3", "3.13.3"] + rspec: ["3.12.3", "3.13.6"] - e2e-regular-minitest: name: e2e-regular__ruby-<< matrix.ruby >>__minitest matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index 52646050..6ec8ef86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### UNRELEASED (minor) +* Allow to [precalculate RSpec Split by Test Examples](https://docs.knapsackpro.com/ruby/reference/#knapsack_pro_rspec_split_by_test_examples_file-rspec) * GitHub Actions: Detect either head branch in Pull Requests or short ref name (vs fully-formed ref) in the other cases https://github.com/KnapsackPro/knapsack_pro-ruby/pull/308 diff --git a/lib/knapsack_pro/adapters/base_adapter.rb b/lib/knapsack_pro/adapters/base_adapter.rb index 0da417f9..bd9d010a 100644 --- a/lib/knapsack_pro/adapters/base_adapter.rb +++ b/lib/knapsack_pro/adapters/base_adapter.rb @@ -15,26 +15,10 @@ def self.split_by_test_cases_enabled? false end - def self.test_file_cases_for(slow_test_files) + def self.calculate_slow_id_paths raise NotImplementedError end - def self.slow_test_file?(adapter_class, test_file_path) - @slow_test_file_paths ||= - begin - slow_test_files = - if KnapsackPro::Config::Env.slow_test_file_pattern - KnapsackPro::TestFileFinder.slow_test_files_by_pattern(adapter_class) - else - # get slow test files from JSON file based on data from API - KnapsackPro::SlowTestFileDeterminer.read_from_json_report - end - KnapsackPro::TestFilePresenter.paths(slow_test_files) - end - clean_path = KnapsackPro::TestFileCleaner.clean(test_file_path) - @slow_test_file_paths.include?(clean_path) - end - def self.bind adapter = new adapter.bind diff --git a/lib/knapsack_pro/adapters/rspec_adapter.rb b/lib/knapsack_pro/adapters/rspec_adapter.rb index 33151926..1edcc594 100644 --- a/lib/knapsack_pro/adapters/rspec_adapter.rb +++ b/lib/knapsack_pro/adapters/rspec_adapter.rb @@ -20,22 +20,17 @@ def self.split_by_test_cases_enabled? true end - def self.test_file_cases_for(slow_test_files) - KnapsackPro.logger.info("Generating RSpec test examples JSON report for slow test files to prepare it to be split by test examples (by individual test cases). Thanks to that, a single slow test file can be split across parallel CI nodes. Analyzing #{slow_test_files.size} slow test files.") - - # generate the RSpec JSON report in a separate process to not pollute the RSpec state + def self.calculate_slow_id_paths + # Shell out not to pollute the RSpec state cmd = [ 'RACK_ENV=test', 'RAILS_ENV=test', KnapsackPro::Config::Env.rspec_test_example_detector_prefix, 'rake knapsack_pro:rspec_test_example_detector', ].join(' ') - unless Kernel.system(cmd) - raise "Could not generate JSON report for RSpec. Rake task failed when running #{cmd}" - end + raise "Failed to calculate Split by Test Examples: #{cmd}" unless Kernel.system(cmd) - # read the JSON report - KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector.new.test_file_example_paths + KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector.new.slow_id_paths! end def self.has_format_option?(cli_args) @@ -86,9 +81,8 @@ def self.id_path?(path) !id.nil? end - def self.concat_paths(tests, id_tests) - paths = KnapsackPro::TestFilePresenter.paths(tests) - id_paths = KnapsackPro::TestFilePresenter.paths(id_tests) + def self.concat_paths(test_files, id_paths) + paths = KnapsackPro::TestFilePresenter.paths(test_files) file_paths = id_paths.map { |id_path| parse_file_path(id_path) } acc = paths + id_paths - file_paths KnapsackPro::TestFilePresenter.test_files(acc) diff --git a/lib/knapsack_pro/build_distribution_fetcher.rb b/lib/knapsack_pro/build_distribution_fetcher.rb index 16763937..db05a58b 100644 --- a/lib/knapsack_pro/build_distribution_fetcher.rb +++ b/lib/knapsack_pro/build_distribution_fetcher.rb @@ -24,8 +24,6 @@ def self.call new.call end - # get test files and time execution for last build distribution matching: - # branch, node_total, node_index def call connection = KnapsackPro::Client::Connection.new(build_action) response = connection.call @@ -33,11 +31,8 @@ def call raise ArgumentError.new(response) if connection.errors? BuildDistributionEntity.new(response) else - KnapsackPro.logger.warn("Slow test files fallback behaviour started. We could not connect with Knapsack Pro API to fetch last CI build test files that are needed to determine slow test files. No test files will be split by test cases. It means all test files will be split by the whole test files as if split by test cases would be disabled #{KnapsackPro::Urls::SPLIT_BY_TEST_EXAMPLES}") - BuildDistributionEntity.new({ - 'time_execution' => 0.0, - 'test_files' => [], - }) + KnapsackPro.logger.warn("Failed to fetch slow test files. Split by Test Examples disabled. See: #{KnapsackPro::Urls::SPLIT_BY_TEST_EXAMPLES}") + BuildDistributionEntity.new({ 'time_execution' => 0.0, 'test_files' => [] }) end end @@ -48,12 +43,21 @@ def repository_adapter end def build_action - KnapsackPro::Client::API::V1::BuildDistributions.last( + request_hash = { commit_hash: repository_adapter.commit_hash, branch: repository_adapter.branch, node_total: KnapsackPro::Config::Env.ci_node_total, - node_index: KnapsackPro::Config::Env.ci_node_index, - ) + node_index: KnapsackPro::Config::Env.ci_node_index + } + + if ENV['KNAPSACK_PRO_PRECALCULATING_SPLIT_BY_TEST_EXAMPLES'] + request_hash.merge!( + node_build_id: KnapsackPro::Config::Env.ci_node_build_id, + none_if_queue_initialized: true + ) + end + + KnapsackPro::Client::API::V1::BuildDistributions.last(request_hash) end end end diff --git a/lib/knapsack_pro/client/api/v1/build_distributions.rb b/lib/knapsack_pro/client/api/v1/build_distributions.rb index 47bf37dc..327edaa3 100644 --- a/lib/knapsack_pro/client/api/v1/build_distributions.rb +++ b/lib/knapsack_pro/client/api/v1/build_distributions.rb @@ -35,16 +35,11 @@ def subset(args) ) end - def last(args) + def last(request_hash) action_class.new( endpoint_path: '/v1/build_distributions/last', http_method: :get, - request_hash: { - :commit_hash => args.fetch(:commit_hash), - :branch => args.fetch(:branch), - :node_total => args.fetch(:node_total), - :node_index => args.fetch(:node_index), - } + request_hash: request_hash ) end end diff --git a/lib/knapsack_pro/config/env.rb b/lib/knapsack_pro/config/env.rb index cb9175eb..863c7b2a 100644 --- a/lib/knapsack_pro/config/env.rb +++ b/lib/knapsack_pro/config/env.rb @@ -67,6 +67,14 @@ def slow_test_file_pattern ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] end + def slow_test_file_threshold + ENV.fetch('KNAPSACK_PRO_SLOW_TEST_FILE_THRESHOLD', nil)&.to_f + end + + def slow_test_file_threshold? + !!slow_test_file_threshold + end + def test_file_exclude_pattern ENV['KNAPSACK_PRO_TEST_FILE_EXCLUDE_PATTERN'] end @@ -200,14 +208,6 @@ def rspec_test_example_detector_prefix ENV.fetch('KNAPSACK_PRO_RSPEC_TEST_EXAMPLE_DETECTOR_PREFIX', 'bundle exec') end - def slow_test_file_threshold - ENV.fetch('KNAPSACK_PRO_SLOW_TEST_FILE_THRESHOLD', nil)&.to_f - end - - def slow_test_file_threshold? - !!slow_test_file_threshold - end - def test_suite_token env_name = 'KNAPSACK_PRO_TEST_SUITE_TOKEN' ENV[env_name] || raise("Missing environment variable #{env_name}. You should set environment variable like #{env_name}_RSPEC (note there is suffix _RSPEC at the end). knapsack_pro gem will set #{env_name} based on #{env_name}_RSPEC value. If you use other test runner than RSpec then use proper suffix.") diff --git a/lib/knapsack_pro/slow_test_file_determiner.rb b/lib/knapsack_pro/slow_test_file_determiner.rb index e488fc88..bf733e72 100644 --- a/lib/knapsack_pro/slow_test_file_determiner.rb +++ b/lib/knapsack_pro/slow_test_file_determiner.rb @@ -18,27 +18,5 @@ def self.call(test_files) execution_time >= KnapsackPro::Config::Env.slow_test_file_threshold end end - - def self.save_to_json_report(test_files) - KnapsackPro::Config::TempFiles.ensure_temp_directory_exists! - FileUtils.mkdir_p(report_dir) - File.write(report_path, test_files.to_json) - end - - def self.read_from_json_report - raise "The report with slow test files has not been generated yet. If you have enabled split by test cases #{KnapsackPro::Urls::SPLIT_BY_TEST_EXAMPLES} and you see this error it means that your tests accidentally cleaned up the .knapsack_pro directory. Please do not remove this directory during tests runtime!" unless File.exist?(report_path) - slow_test_files_json_report = File.read(report_path) - JSON.parse(slow_test_files_json_report) - end - - private - - def self.report_path - "#{report_dir}/slow_test_files_node_#{KnapsackPro::Config::Env.ci_node_index}.json" - end - - def self.report_dir - "#{KnapsackPro::Config::TempFiles::TEMP_DIRECTORY_PATH}/slow_test_file_determiner" - end end end diff --git a/lib/knapsack_pro/slow_test_file_finder.rb b/lib/knapsack_pro/slow_test_file_finder.rb index cad0ecb0..747e1f6a 100644 --- a/lib/knapsack_pro/slow_test_file_finder.rb +++ b/lib/knapsack_pro/slow_test_file_finder.rb @@ -11,19 +11,11 @@ def self.call(adapter_class) raise "Split by test cases is not possible when you have enabled test file names encryption ( #{KnapsackPro::Urls::ENCRYPTION} ). You need to disable encryption with KNAPSACK_PRO_TEST_FILES_ENCRYPTED=false in order to use split by test cases #{KnapsackPro::Urls::SPLIT_BY_TEST_EXAMPLES}" end - # get list of recorded test files for last CI Build build_distribution_entity = KnapsackPro::BuildDistributionFetcher.call test_files_from_api = build_distribution_entity.test_files - merged_test_files_from_api = KnapsackPro::TestCaseMergers::RSpecMerger.new(test_files_from_api).call - test_files_existing_on_disk = KnapsackPro::TestFileFinder.select_test_files_that_can_be_run(adapter_class, merged_test_files_from_api) - - slow_test_files = KnapsackPro::SlowTestFileDeterminer.call(test_files_existing_on_disk) - - KnapsackPro::SlowTestFileDeterminer.save_to_json_report(slow_test_files) - - slow_test_files + KnapsackPro::SlowTestFileDeterminer.call(test_files_existing_on_disk) end end end diff --git a/lib/knapsack_pro/test_case_detectors/rspec_test_example_detector.rb b/lib/knapsack_pro/test_case_detectors/rspec_test_example_detector.rb index 21528102..15bf02f0 100644 --- a/lib/knapsack_pro/test_case_detectors/rspec_test_example_detector.rb +++ b/lib/knapsack_pro/test_case_detectors/rspec_test_example_detector.rb @@ -3,115 +3,92 @@ module KnapsackPro module TestCaseDetectors class RSpecTestExampleDetector - def generate_json_report(rspec_args) - raise "The internal KNAPSACK_PRO_RSPEC_OPTIONS environment variable is unset. Ensure it is not overridden accidentally. Otherwise, please report this as a bug: #{KnapsackPro::Urls::SUPPORT}" if rspec_args.nil? - - require 'rspec/core' - - cli_format = - if Gem::Version.new(::RSpec::Core::Version::STRING) < Gem::Version.new('3.6.0') - require_relative '../formatters/rspec_json_formatter' - ['--format', KnapsackPro::Formatters::RSpecJsonFormatter.to_s] - else - ['--format', 'json'] - end - - ensure_report_dir_exists - remove_old_json_report - - test_file_entities = slow_test_files - - if test_file_entities.empty? - no_examples_json = { examples: [] }.to_json - File.write(report_path, no_examples_json) - return - end + def calculate(rspec_args) + KnapsackPro::Config::TempFiles.ensure_temp_directory_exists! + FileUtils.mkdir_p(File.dirname(report_path)) + File.delete(report_path) if File.exist?(report_path) + return File.write(report_path, { examples: [] }.to_json) if slow_test_files.empty? + KnapsackPro.logger.info("Calculating Split by Test Examples. Analyzing #{slow_test_files.size} slow test files.") args = (rspec_args || '').split cli_args_without_formatters = KnapsackPro::Adapters::RSpecAdapter.remove_formatters(args) - - # Apply a --format option which overrides formatters from the RSpec custom option files like `.rspec`. cli_args = cli_args_without_formatters + cli_format + [ '--dry-run', '--out', report_path, '--default-path', test_dir - ] + KnapsackPro::TestFilePresenter.paths(test_file_entities) - exit_code = begin - options = ::RSpec::Core::ConfigurationOptions.new(cli_args) - ::RSpec::Core::Runner.new(options).run($stderr, $stdout) - rescue SystemExit => e - e.status - end - + ] + KnapsackPro::TestFilePresenter.paths(slow_test_files) + exit_code = dry_run(cli_args) return if exit_code.zero? report.fetch('messages', []).each { |message| puts message } command = (['bundle exec rspec'] + cli_args).join(' ') - KnapsackPro.logger.error("Failed to generate the slow test files report: #{command}") + KnapsackPro.logger.error("Failed to calculate Split by Test Examples: #{command}") exit exit_code end - def test_file_example_paths - raise "No report found at #{report_path}" unless File.exist?(report_path) - - json_report = File.read(report_path) - hash_report = JSON.parse(json_report) - hash_report - .fetch('examples') - .map { |e| e.fetch('id') } - .map { |path_with_example_id| test_file_hash_for(path_with_example_id) } - end + # Apply a --format option which overrides formatters from the RSpec custom option files like `.rspec`. + def cli_format + require 'rspec/core' - def slow_test_files - if KnapsackPro::Config::Env.slow_test_file_pattern - KnapsackPro::TestFileFinder.slow_test_files_by_pattern(adapter_class) + if Gem::Version.new(::RSpec::Core::Version::STRING) < Gem::Version.new('3.6.0') + require_relative '../formatters/rspec_json_formatter' + ['--format', KnapsackPro::Formatters::RSpecJsonFormatter.to_s] else - # read slow test files from JSON file on disk that was generated - # by lib/knapsack_pro/base_allocator_builder.rb - KnapsackPro::SlowTestFileDeterminer.read_from_json_report + ['--format', 'json'] end end - private + def dry_run(cli_args) + require 'rspec/core' - def report_dir - "#{KnapsackPro::Config::TempFiles::TEMP_DIRECTORY_PATH}/test_case_detectors/rspec" + options = ::RSpec::Core::ConfigurationOptions.new(cli_args) + ::RSpec::Core::Runner.new(options).run($stderr, $stdout) + rescue SystemExit => e + e.status end - def report_path - "#{report_dir}/rspec_dry_run_json_report_node_#{KnapsackPro::Config::Env.ci_node_index}.json" - end + def slow_id_paths! + raise "No report found at #{report_path}" unless File.exist?(report_path) - def report - return {} unless File.exist?(report_path) JSON.parse(File.read(report_path)) + .fetch('examples') + .map { |example| TestFileCleaner.clean(example.fetch('id')) } end - def adapter_class - KnapsackPro::Adapters::RSpecAdapter + def precalculated_slow_id_paths + return nil if ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES_FILE'].nil? + + slow_id_paths! end - def test_dir - KnapsackPro::Config::Env.test_dir || KnapsackPro::TestFilePattern.test_dir(adapter_class) + def report_path + ENV['KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES_FILE'] || + "#{KnapsackPro::Config::TempFiles::TEMP_DIRECTORY_PATH}/test_case_detectors/rspec/rspec_dry_run_json_report_node_#{KnapsackPro::Config::Env.ci_node_index}.json" end - def test_file_pattern - KnapsackPro::TestFilePattern.call(adapter_class) + private + + def slow_test_files + @slow_test_files ||= + if KnapsackPro::Config::Env.slow_test_file_pattern + KnapsackPro::TestFileFinder.slow_test_files_by_pattern(adapter_class) + else + KnapsackPro::SlowTestFileFinder.call(adapter_class) + end end - def ensure_report_dir_exists - KnapsackPro::Config::TempFiles.ensure_temp_directory_exists! - FileUtils.mkdir_p(report_dir) + def report + return {} unless File.exist?(report_path) + + JSON.parse(File.read(report_path)) end - def remove_old_json_report - File.delete(report_path) if File.exist?(report_path) + def adapter_class + KnapsackPro::Adapters::RSpecAdapter end - def test_file_hash_for(test_file_path) - { - 'path' => TestFileCleaner.clean(test_file_path) - } + def test_dir + KnapsackPro::Config::Env.test_dir || KnapsackPro::TestFilePattern.test_dir(adapter_class) end end end diff --git a/lib/knapsack_pro/test_case_mergers/rspec_merger.rb b/lib/knapsack_pro/test_case_mergers/rspec_merger.rb index ad26522f..8f13341a 100644 --- a/lib/knapsack_pro/test_case_mergers/rspec_merger.rb +++ b/lib/knapsack_pro/test_case_mergers/rspec_merger.rb @@ -13,20 +13,20 @@ def call @test_files.each do |test_file| raw_path = test_file.fetch('path') - path = KnapsackPro::Adapters::RSpecAdapter.parse_file_path(raw_path) + file_path = KnapsackPro::Adapters::RSpecAdapter.parse_file_path(raw_path) if KnapsackPro::Adapters::RSpecAdapter.id_path?(raw_path) - id_paths[path] ||= 0.0 - id_paths[path] += test_file.fetch('time_execution') + id_paths[file_path] ||= 0.0 + id_paths[file_path] += test_file.fetch('time_execution') else - file_paths[path] = test_file.fetch('time_execution') # may be nil + file_paths[file_path] = test_file.fetch('time_execution') # may be nil end end file_paths .merge(id_paths) { |_, v1, v2| [v1, v2].compact.max } - .map do |path, time_execution| - { 'path' => path, 'time_execution' => time_execution } + .map do |file_path, time_execution| + { 'path' => file_path, 'time_execution' => time_execution } end end end diff --git a/lib/knapsack_pro/test_file_finder.rb b/lib/knapsack_pro/test_file_finder.rb index 53bd7887..59b5ed95 100644 --- a/lib/knapsack_pro/test_file_finder.rb +++ b/lib/knapsack_pro/test_file_finder.rb @@ -6,19 +6,13 @@ def self.call(test_file_pattern, test_file_list_enabled: true) new(test_file_pattern, test_file_list_enabled).call end - # finds slow test files on disk based on ENV patterns - # returns example: [{ 'path' => 'a_spec.rb' }] def self.slow_test_files_by_pattern(adapter_class) raise 'KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN is not defined' unless KnapsackPro::Config::Env.slow_test_file_pattern test_file_pattern = KnapsackPro::TestFilePattern.call(adapter_class) - test_file_entities = call(test_file_pattern) - - slow_test_file_entities = call(KnapsackPro::Config::Env.slow_test_file_pattern, test_file_list_enabled: false) - - # slow test files (KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN) - # should be subset of test file pattern (KNAPSACK_PRO_TEST_FILE_PATTERN) - slow_test_file_entities & test_file_entities + scheduled_test_files = call(test_file_pattern) + slow_test_files = call(KnapsackPro::Config::Env.slow_test_file_pattern, test_file_list_enabled: false) + scheduled_test_files & slow_test_files end def self.select_test_files_that_can_be_run(adapter_class, candidate_test_files) @@ -26,8 +20,8 @@ def self.select_test_files_that_can_be_run(adapter_class, candidate_test_files) scheduled_test_files = call(test_file_pattern) scheduled_paths = KnapsackPro::TestFilePresenter.paths(scheduled_test_files) candidate_paths = KnapsackPro::TestFilePresenter.paths(candidate_test_files) - intersection = scheduled_paths & candidate_paths - KnapsackPro::TestFilePresenter.test_files(intersection) + paths = scheduled_paths & candidate_paths + candidate_test_files.filter { |test_file| paths.include? test_file.fetch('path') } end def initialize(test_file_pattern, test_file_list_enabled) diff --git a/lib/knapsack_pro/test_suite.rb b/lib/knapsack_pro/test_suite.rb index c985421e..6c1e0efc 100644 --- a/lib/knapsack_pro/test_suite.rb +++ b/lib/knapsack_pro/test_suite.rb @@ -8,8 +8,6 @@ def initialize(adapter_class) @adapter_class = adapter_class end - # Detect test files present on the disk that should be run. - # This may include fast test files + slow test files split by test cases. def calculate_test_files return @result if defined?(@result) @@ -17,24 +15,20 @@ def calculate_test_files return @result = Result.new(all_test_files_to_run, true) end - slow_test_files, quick = - if slow_test_file_pattern - [KnapsackPro::TestFileFinder.slow_test_files_by_pattern(adapter_class), true] - else - [KnapsackPro::SlowTestFileFinder.call(adapter_class), false] - end - - KnapsackPro.logger.debug("Detected #{slow_test_files.size} slow test files: #{slow_test_files.inspect}") - - if slow_test_files.empty? - return @result = Result.new(all_test_files_to_run, quick) + unless (slow_id_paths = KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector.new.precalculated_slow_id_paths).nil? + KnapsackPro.logger.info('Using precalculated Split by Test Examples.') + test_files = adapter_class.concat_paths(all_test_files_to_run, slow_id_paths) + return @result = Result.new(test_files, true) end - test_file_cases = adapter_class.test_file_cases_for(slow_test_files) - - fast_files_and_cases_for_slow_tests = adapter_class.concat_paths(all_test_files_to_run, test_file_cases) + if KnapsackPro::Config::Env.slow_test_file_pattern + slow_test_files = KnapsackPro::TestFileFinder.slow_test_files_by_pattern(adapter_class) + return @result = Result.new(all_test_files_to_run, true) if slow_test_files.empty? + end - @result = Result.new(fast_files_and_cases_for_slow_tests, false) + slow_id_paths = adapter_class.calculate_slow_id_paths + test_files = adapter_class.concat_paths(all_test_files_to_run, slow_id_paths) + @result = Result.new(test_files, false) end # In Fallback Mode, we always want to run whole test files (not split by @@ -49,15 +43,7 @@ def fallback_test_files attr_reader :adapter_class def all_test_files_to_run - @all_test_files_to_run ||= KnapsackPro::TestFileFinder.call(test_file_pattern) - end - - def test_file_pattern - TestFilePattern.call(adapter_class) - end - - def slow_test_file_pattern - KnapsackPro::Config::Env.slow_test_file_pattern + @all_test_files_to_run ||= KnapsackPro::TestFileFinder.call(TestFilePattern.call(adapter_class)) end end end diff --git a/lib/knapsack_pro/urls.rb b/lib/knapsack_pro/urls.rb index a6f1e4de..3dbc6016 100644 --- a/lib/knapsack_pro/urls.rb +++ b/lib/knapsack_pro/urls.rb @@ -34,6 +34,8 @@ module Urls SPLIT_BY_TEST_EXAMPLES = "#{HOST}/perma/ruby/split-by-test-examples" + SPLIT_BY_TEST_EXAMPLES_FILE = "#{HOST}/perma/ruby/split-by-test-examples-file" + TEST_UNIT__TEST_FILE_PATH_DETECTION = "#{HOST}/perma/ruby/test-unit-test-file-path-detection" end end diff --git a/lib/tasks/rspec.rake b/lib/tasks/rspec.rake index bcdf896a..e219b7c3 100644 --- a/lib/tasks/rspec.rake +++ b/lib/tasks/rspec.rake @@ -7,12 +7,35 @@ namespace :knapsack_pro do KnapsackPro::Runners::RSpecRunner.run(args[:rspec_args]) end - desc "Generate JSON report for test suite based on default test pattern or based on defined pattern with ENV vars" + # private task :rspec_test_example_detector do - # ignore the `SPEC_OPTS` options to not affect RSpec execution within this rake task + key = 'KNAPSACK_PRO_RSPEC_OPTIONS' + raise "The internal #{key} environment variable is unset. Ensure it is not overridden accidentally. Otherwise, please report this as a bug: #{KnapsackPro::Urls::SUPPORT}" if ENV[key].nil? + + # Ignore `SPEC_OPTS` to not affect the RSpec execution within this rake task ENV.delete('SPEC_OPTS') - detector = KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector.new - detector.generate_json_report(ENV['KNAPSACK_PRO_RSPEC_OPTIONS']) + KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector + .new + .calculate(ENV[key]) + end + + namespace :rspec do + desc 'Precalculate Split by Test Examples into KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES_FILE (to avoid doing it later in each node running Knapsack Pro)' + task :precalculate_split_by_test_examples, [:rspec_args] do |_, args| + ENV['KNAPSACK_PRO_PRECALCULATING_SPLIT_BY_TEST_EXAMPLES'] = 'true' + + key = 'KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES_FILE' + raise "Missing #{key}. See: #{KnapsackPro::Urls::SPLIT_BY_TEST_EXAMPLES_FILE}" if ENV[key].nil? + + ENV['KNAPSACK_PRO_TEST_SUITE_TOKEN'] = KnapsackPro::Config::Env.test_suite_token_rspec + + # Ignore `SPEC_OPTS` to not affect the RSpec execution within this rake task + ENV.delete('SPEC_OPTS') + + KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector + .new + .calculate(args[:rspec_args].to_s) + end end end diff --git a/spec/integration/runners/queue/rspec_runner.rb b/spec/integration/runners/queue/rspec_runner.rb index 6cb0f4e8..c75787aa 100644 --- a/spec/integration/runners/queue/rspec_runner.rb +++ b/spec/integration/runners/queue/rspec_runner.rb @@ -9,7 +9,7 @@ RSPEC_OPTIONS = ENV.fetch('TEST__RSPEC_OPTIONS') SHOW_DEBUG_LOG = ENV['TEST__SHOW_DEBUG_LOG'] == 'true' -SPEC_BATCHES = JSON.load(ENV.fetch('TEST__SPEC_BATCHES')) +BATCHES = JSON.load(ENV.fetch('TEST__BATCHES')) class IntegrationTestLogger def self.log(message) @@ -21,8 +21,8 @@ module KnapsackProExtensions module QueueAllocatorExtension # Succeeds to initialize on the first request def initialize_queue(tests_to_run, batch_uuid) - # Ensure the stubbed batches matches the tests Knapsack Pro wants to run - raise unless tests_to_run.map { _1["path"] }.sort == SPEC_BATCHES.flatten.sort + # Ensure the stubbed batches match the tests Knapsack Pro wants to run + raise unless tests_to_run.map { _1["path"] }.sort == BATCHES.flatten.sort test__pull end @@ -39,7 +39,7 @@ def pull_tests_from_queue(can_initialize_queue, batch_uuid) def test__pull @batch_index ||= 0 last_batch = [] - batches = [*SPEC_BATCHES, last_batch] + batches = [*BATCHES, last_batch] tests = batches[@batch_index] @batch_index += 1 @@ -65,13 +65,8 @@ def create_build_subset(test_files) end module RSpecAdapter - def test_file_cases_for(slow_test_files) - IntegrationTestLogger.log("Stubbed test file cases for slow test files: #{slow_test_files}") - - test_file_paths = JSON.load(ENV.fetch('TEST__TEST_FILE_CASES_FOR_SLOW_TEST_FILES')) - test_file_paths.map do |path| - { 'path' => path } - end + def calculate_slow_id_paths + JSON.load(ENV.fetch('TEST__SLOW_ID_PATHS')) end end end diff --git a/spec/integration/runners/queue/rspec_runner_spec.rb b/spec/integration/runners/queue/rspec_runner_spec.rb index 274ce07b..5ea1c80d 100644 --- a/spec/integration/runners/queue/rspec_runner_spec.rb +++ b/spec/integration/runners/queue/rspec_runner_spec.rb @@ -16,13 +16,13 @@ def initialize(path, content) end # @param rspec_options String - # @param spec_batches Array[Array[String]] - def generate_specs(spec_helper, rspec_options, spec_batches) + # @param batches Array[Array[String]] + def generate_specs(spec_helper, rspec_options, batches) ENV['TEST__RSPEC_OPTIONS'] = rspec_options generate_spec_helper(spec_helper) - paths = generate_spec_files(spec_batches.flatten) - stub_spec_batches( - spec_batches.map { _1.map(&:path) } + paths = generate_spec_files(batches.flatten) + stub_batches( + batches.map { _1.map(&:path) } ) end @@ -44,14 +44,14 @@ def create_rails_helper_file(rails_helper) end # The batches returned by each call to initialize_queue or pull_tests_from_queue - def stub_spec_batches(batched_tests) - ENV['TEST__SPEC_BATCHES'] = batched_tests.to_json + def stub_batches(batched_tests) + ENV['TEST__BATCHES'] = batched_tests.to_json end # @param test_file_paths Array[String] # Example: ['spec_integration/a_spec.rb[1:1]'] - def stub_test_cases_for_slow_test_files(test_file_paths) - ENV['TEST__TEST_FILE_CASES_FOR_SLOW_TEST_FILES'] = test_file_paths.to_json + def stub_slow_id_paths(id_paths) + ENV['TEST__SLOW_ID_PATHS'] = id_paths.to_json end def log(stdout, stderr, status) @@ -1971,7 +1971,6 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end - after do ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') ENV.delete('KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN') @@ -2017,11 +2016,11 @@ def when_first_matching_example_defined(type:) generate_specs(spec_helper_with_knapsack, rspec_options, [ [spec_a, spec_b, spec_c] ]) - stub_test_cases_for_slow_test_files([ + stub_slow_id_paths([ "#{spec_a.path}[1:1]", "#{spec_a.path}[1:2]", ]) - stub_spec_batches([ + stub_batches([ ["#{spec_a.path}[1:1]", spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) @@ -2113,10 +2112,10 @@ def when_first_matching_example_defined(type:) generate_specs(spec_helper_with_knapsack, rspec_options, [ [spec_a, spec_b, spec_c] ]) - stub_test_cases_for_slow_test_files([ + stub_slow_id_paths([ "#{spec_a.path}[1:2]", # only this test example is tagged ]) - stub_spec_batches([ + stub_batches([ [spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) @@ -2192,11 +2191,11 @@ def when_first_matching_example_defined(type:) generate_specs(spec_helper_with_knapsack, rspec_options, [ [spec_a, spec_b, spec_c] ]) - stub_test_cases_for_slow_test_files([ + stub_slow_id_paths([ "#{spec_a.path}[1:1]", "#{spec_a.path}[1:2]", ]) - stub_spec_batches([ + stub_batches([ ["#{spec_a.path}[1:1]", spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) @@ -2293,11 +2292,11 @@ def when_first_matching_example_defined(type:) generate_specs(spec_helper_with_knapsack, rspec_options, [ [spec_a, spec_b, spec_c] ]) - stub_test_cases_for_slow_test_files([ + stub_slow_id_paths([ "#{spec_a.path}[1:1]", "#{spec_a.path}[1:2]", ]) - stub_spec_batches([ + stub_batches([ ["#{spec_a.path}[1:1]", spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) @@ -2402,11 +2401,11 @@ def when_first_matching_example_defined(type:) generate_specs(spec_helper, rspec_options, [ [spec_a, spec_b, spec_c] ]) - stub_test_cases_for_slow_test_files([ + stub_slow_id_paths([ "#{spec_a.path}[1:1]", "#{spec_a.path}[1:2]", ]) - stub_spec_batches([ + stub_batches([ ["#{spec_a.path}[1:1]", spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) @@ -2429,7 +2428,6 @@ def when_first_matching_example_defined(type:) ENV['KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN'] = "" ENV['KNAPSACK_PRO_CI_NODE_TOTAL'] = '2' end - after do ENV.delete('KNAPSACK_PRO_TEST_FILE_LIST') ENV.delete('KNAPSACK_PRO_RSPEC_SPLIT_BY_TEST_EXAMPLES') @@ -2478,7 +2476,7 @@ def when_first_matching_example_defined(type:) [spec_a, spec_b, spec_c] ]) - stub_spec_batches([ + stub_batches([ ["#{spec_a.path}[1:1]", spec_b.path], ["#{spec_a.path}[1:2]", spec_c.path], ]) diff --git a/spec/knapsack_pro/adapters/base_adapter_spec.rb b/spec/knapsack_pro/adapters/base_adapter_spec.rb index 0c2c3161..103e341e 100644 --- a/spec/knapsack_pro/adapters/base_adapter_spec.rb +++ b/spec/knapsack_pro/adapters/base_adapter_spec.rb @@ -48,48 +48,12 @@ it { expect(subject).to be false } end - describe '.test_file_cases_for' do - subject { described_class.test_file_cases_for([]) } + describe '.calculate_slow_id_paths' do + subject { described_class.calculate_slow_id_paths } it { expect { subject }.to raise_error NotImplementedError } end - describe '.slow_test_file?' do - let(:adapter_class) { double } - let(:slow_test_files) do - [ - { 'path' => 'spec/models/user_spec.rb' }, - { 'path' => 'spec/controllers/users_spec.rb' }, - ] - end - - subject { described_class.slow_test_file?(adapter_class, test_file_path) } - - before do - # reset class variable - described_class.instance_variable_set(:@slow_test_file_paths, nil) - end - - context 'when slow test file pattern is present' do - before do - stub_const('ENV', { - 'KNAPSACK_PRO_SLOW_TEST_FILE_PATTERN' => '{spec/models/*_spec.rb}', - }) - expect(KnapsackPro::TestFileFinder).to receive(:slow_test_files_by_pattern).with(adapter_class).and_return(slow_test_files) - end - - it_behaves_like '.slow_test_file? method' - end - - context 'when slow test file pattern is not present' do - before do - expect(KnapsackPro::SlowTestFileDeterminer).to receive(:read_from_json_report).and_return(slow_test_files) - end - - it_behaves_like '.slow_test_file? method' - end - end - describe '.bind' do let(:adapter) { instance_double(described_class) } diff --git a/spec/knapsack_pro/adapters/rspec_adapter_spec.rb b/spec/knapsack_pro/adapters/rspec_adapter_spec.rb index 0d9ace18..dfea6c1f 100644 --- a/spec/knapsack_pro/adapters/rspec_adapter_spec.rb +++ b/spec/knapsack_pro/adapters/rspec_adapter_spec.rb @@ -44,24 +44,10 @@ end end - describe '.test_file_cases_for' do - let(:slow_test_files) do - [ - '1_spec.rb', - '2_spec.rb', - '3_spec.rb', - '4_spec.rb', - '5_spec.rb', - ] - end - - subject { described_class.test_file_cases_for(slow_test_files) } + describe '.calculate_slow_id_paths' do + subject { described_class.calculate_slow_id_paths } before do - logger = instance_double(Logger) - expect(KnapsackPro).to receive(:logger).and_return(logger) - expect(logger).to receive(:info).with("Generating RSpec test examples JSON report for slow test files to prepare it to be split by test examples (by individual test cases). Thanks to that, a single slow test file can be split across parallel CI nodes. Analyzing 5 slow test files.") - cmd = 'RACK_ENV=test RAILS_ENV=test bundle exec rake knapsack_pro:rspec_test_example_detector' expect(Kernel).to receive(:system).with(cmd).and_return(cmd_result) end @@ -73,10 +59,10 @@ rspec_test_example_detector = instance_double(KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector) expect(KnapsackPro::TestCaseDetectors::RSpecTestExampleDetector).to receive(:new).and_return(rspec_test_example_detector) - test_file_example_paths = double - expect(rspec_test_example_detector).to receive(:test_file_example_paths).and_return(test_file_example_paths) + slow_id_paths = double + expect(rspec_test_example_detector).to receive(:slow_id_paths!).and_return(slow_id_paths) - expect(subject).to eq test_file_example_paths + expect(subject).to eq slow_id_paths end end @@ -84,7 +70,7 @@ let(:cmd_result) { false } it do - expect { subject }.to raise_error(RuntimeError, 'Could not generate JSON report for RSpec. Rake task failed when running RACK_ENV=test RAILS_ENV=test bundle exec rake knapsack_pro:rspec_test_example_detector') + expect { subject }.to raise_error(RuntimeError, 'Failed to calculate Split by Test Examples: RACK_ENV=test RAILS_ENV=test bundle exec rake knapsack_pro:rspec_test_example_detector') end end end @@ -353,7 +339,7 @@ end describe '.concat_paths' do - let(:tests) do + let(:test_files) do [ { 'path' => 'spec/a_spec.rb' }, { 'path' => 'spec/b_spec.rb' }, @@ -363,19 +349,19 @@ ] end - let(:id_tests) do + let(:id_paths) do [ - { 'path' => 'spec/slow_1_spec.rb[1:1]' }, - { 'path' => 'spec/slow_1_spec.rb[1:2]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:1]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:2]' }, - { 'path' => 'spec/slow_2_spec.rb[1:1:3]' }, + 'spec/slow_1_spec.rb[1:1]', + 'spec/slow_1_spec.rb[1:2]', + 'spec/slow_2_spec.rb[1:1:1]', + 'spec/slow_2_spec.rb[1:1:2]', + 'spec/slow_2_spec.rb[1:1:3]', ] end - subject { described_class.concat_paths(tests, id_tests) } + subject { described_class.concat_paths(test_files, id_paths) } - it 'concats by replacing tests with the associated id_tests' do + it 'concats by replacing test_files with the associated id_paths' do expect(subject).to eq([ { 'path' => 'spec/a_spec.rb' }, { 'path' => 'spec/b_spec.rb' }, diff --git a/spec/knapsack_pro/build_distribution_fetcher_spec.rb b/spec/knapsack_pro/build_distribution_fetcher_spec.rb index e6fb0638..58a7fd03 100644 --- a/spec/knapsack_pro/build_distribution_fetcher_spec.rb +++ b/spec/knapsack_pro/build_distribution_fetcher_spec.rb @@ -15,6 +15,7 @@ describe '#call' do let(:ci_node_total) { double } let(:ci_node_index) { double } + let(:ci_node_build_id) { double } let(:repository_adapter) { instance_double(KnapsackPro::RepositoryAdapters::EnvAdapter, commit_hash: double, branch: double) } subject { described_class.new.call } @@ -30,7 +31,7 @@ commit_hash: repository_adapter.commit_hash, branch: repository_adapter.branch, node_total: ci_node_total, - node_index: ci_node_index, + node_index: ci_node_index }).and_return(action) connection = instance_double(KnapsackPro::Client::Connection, diff --git a/spec/knapsack_pro/client/api/v1/build_distributions_spec.rb b/spec/knapsack_pro/client/api/v1/build_distributions_spec.rb index 99ded5ce..3580d39a 100644 --- a/spec/knapsack_pro/client/api/v1/build_distributions_spec.rb +++ b/spec/knapsack_pro/client/api/v1/build_distributions_spec.rb @@ -63,33 +63,12 @@ end describe '.last' do - let(:commit_hash) { double } - let(:branch) { double } - let(:node_total) { double } - let(:node_index) { double } - - subject do - described_class.last( - commit_hash: commit_hash, - branch: branch, - node_total: node_total, - node_index: node_index, - ) - end - it do - action = double - expect(KnapsackPro::Client::API::Action).to receive(:new).with({ - endpoint_path: '/v1/build_distributions/last', - http_method: :get, - request_hash: { - commit_hash: commit_hash, - branch: branch, - node_total: node_total, - node_index: node_index, - } - }).and_return(action) - expect(subject).to eq action + actual = described_class.last(key: 'value') + + expect(actual.endpoint_path).to eq '/v1/build_distributions/last' + expect(actual.http_method).to eq :get + expect(actual.request_hash).to eq(key: 'value') end end end diff --git a/spec/knapsack_pro/slow_test_file_determiner_spec.rb b/spec/knapsack_pro/slow_test_file_determiner_spec.rb index 1393ca5f..91978923 100644 --- a/spec/knapsack_pro/slow_test_file_determiner_spec.rb +++ b/spec/knapsack_pro/slow_test_file_determiner_spec.rb @@ -70,50 +70,4 @@ end end end - - describe '.save_to_json_report', :clear_tmp do - let(:json_report_path) { '.knapsack_pro/slow_test_file_determiner/slow_test_files_node_0.json' } - let(:test_files) do - [ - { 'path' => 'a_spec.rb', 'time_execution' => 1.0 }, - # unique path to ensure we saved on disk a completely new report - { 'path' => "#{SecureRandom.hex}_spec.rb", 'time_execution' => 3.4 }, - ] - end - - subject { described_class.save_to_json_report(test_files) } - - it do - subject - expect(File.read(json_report_path)).to eq(test_files.to_json) - end - end - - describe '.read_from_json_report', :clear_tmp do - let(:test_files) do - [ - { 'path' => 'a_spec.rb', 'time_execution' => 1.0 }, - # unique path to ensure we saved on disk a completely new report - { 'path' => "#{SecureRandom.hex}_spec.rb", 'time_execution' => 3.4 }, - ] - end - - subject { described_class.read_from_json_report } - - context 'when json report exists' do - before do - described_class.save_to_json_report(test_files) - end - - it do - expect(subject).to eq test_files - end - end - - context 'when json report does not exist' do - it do - expect { subject }.to raise_error(RuntimeError, 'The report with slow test files has not been generated yet. If you have enabled split by test cases https://knapsackpro.com/perma/ruby/split-by-test-examples and you see this error it means that your tests accidentally cleaned up the .knapsack_pro directory. Please do not remove this directory during tests runtime!') - end - end - end end diff --git a/spec/knapsack_pro/slow_test_file_finder_spec.rb b/spec/knapsack_pro/slow_test_file_finder_spec.rb index ffd35ace..ffa645a8 100644 --- a/spec/knapsack_pro/slow_test_file_finder_spec.rb +++ b/spec/knapsack_pro/slow_test_file_finder_spec.rb @@ -27,8 +27,6 @@ slow_test_files = double expect(KnapsackPro::SlowTestFileDeterminer).to receive(:call).with(test_files_existing_on_disk).and_return(slow_test_files) - expect(KnapsackPro::SlowTestFileDeterminer).to receive(:save_to_json_report).with(slow_test_files) - expect(subject).to eq slow_test_files end end diff --git a/spec/knapsack_pro/test_case_detectors/rspec_test_example_detector_spec.rb b/spec/knapsack_pro/test_case_detectors/rspec_test_example_detector_spec.rb index cc5fa5c3..250d9a12 100644 --- a/spec/knapsack_pro/test_case_detectors/rspec_test_example_detector_spec.rb +++ b/spec/knapsack_pro/test_case_detectors/rspec_test_example_detector_spec.rb @@ -3,10 +3,10 @@ let(:report_path) { '.knapsack_pro/test_case_detectors/rspec/rspec_dry_run_json_report_node_0.json' } let(:rspec_test_example_detector) { described_class.new } - describe '#generate_json_report' do - subject { rspec_test_example_detector.generate_json_report(rspec_args) } + describe '#calculate' do + subject { rspec_test_example_detector.calculate(rspec_args) } - shared_examples 'generate_json_report runs RSpec::Core::Runner' do + shared_examples 'calculate runs RSpec::Core::Runner' do before do expect(KnapsackPro::Config::TempFiles).to receive(:ensure_temp_directory_exists!) @@ -16,7 +16,7 @@ expect(File).to receive(:exist?).at_least(:once).with(report_path).and_return(true) expect(File).to receive(:delete).with(report_path) - expect(rspec_test_example_detector).to receive(:slow_test_files).and_return(test_file_entities) + expect(rspec_test_example_detector).to receive(:slow_test_files).at_least(1).time.and_return(test_file_entities) end context 'when there are no slow test files' do @@ -27,7 +27,7 @@ end it do - expect(subject).to be_nil + subject end end @@ -38,6 +38,7 @@ { 'path' => 'spec/b_spec.rb' }, ] end + let(:rspec_core_runner) { double } before do test_dir = 'spec' @@ -53,31 +54,29 @@ 'spec/a_spec.rb', 'spec/b_spec.rb', ]).and_return(options) - rspec_core_runner = double expect(RSpec::Core::Runner).to receive(:new).with(options).and_return(rspec_core_runner) - expect(rspec_core_runner).to receive(:run).with($stderr, $stdout).and_return(exit_code) end context 'when exit code from RSpec::Core::Runner is 0' do - let(:exit_code) { 0 } + before do + expect(rspec_core_runner).to receive(:run).with($stderr, $stdout).and_return(0) + end it do - expect(subject).to be_nil + subject end end context 'when exit code from RSpec::Core::Runner is 1' do - let(:exit_code) { 1 } - before do - json_file = %{ - {"version":"3.11.0","messages":["An error occurred while loading ./spec/a_spec.rb"],"examples":[],"summary":{"duration":3.6e-05,"example_count":0,"failure_count":0,"pending_count":0,"errors_outside_of_examples_count":1},"summary_line":"0 examples, 0 failures, 1 error occurred outside of examples"} - }.strip - expect(File).to receive(:read).with(report_path).and_return(json_file) + expect(rspec_core_runner).to receive(:run).with($stderr, $stdout).and_return(1) + expect(File).to receive(:read).with(report_path).and_return(<<~JSON.strip) + {"version":"3.11.0","messages":["An error occurred while loading ./spec/a_spec.rb"],"examples":[],"summary":{"duration":3.6e-05,"example_count":0,"failure_count":0,"pending_count":0,"errors_outside_of_examples_count":1},"summary_line":"0 examples, 0 failures, 1 error occurred outside of examples"} + JSON end it do - expect { subject }.to raise_error(SystemExit) { |error| expect(error.status).to eq exit_code } + expect { subject }.to raise_error(SystemExit) { |error| expect(error.status).to eq 1 } end end end @@ -88,7 +87,7 @@ let(:expected_args) { [] } let(:expected_format) { 'json' } - it_behaves_like 'generate_json_report runs RSpec::Core::Runner' + it_behaves_like 'calculate runs RSpec::Core::Runner' end context 'when RSpec < 3.6.0' do @@ -100,7 +99,7 @@ stub_const('RSpec::Core::Version::STRING', '3.5.0') end - it_behaves_like 'generate_json_report runs RSpec::Core::Runner' + it_behaves_like 'calculate runs RSpec::Core::Runner' end context 'when RSpec CLI args are present including format option' do @@ -109,17 +108,7 @@ let(:expected_format) { 'json' } describe 'removes formatters from RSpec CLI args' do - it_behaves_like 'generate_json_report runs RSpec::Core::Runner' - end - end - - context 'when RSpec CLI args are not set' do - let(:rspec_args) { nil } - let(:expected_args) { [] } - let(:expected_format) { 'json' } - - it do - expect { subject }.to raise_error("The internal KNAPSACK_PRO_RSPEC_OPTIONS environment variable is unset. Ensure it is not overridden accidentally. Otherwise, please report this as a bug: https://knapsackpro.com/perma/ruby/support") + it_behaves_like 'calculate runs RSpec::Core::Runner' end end @@ -128,7 +117,7 @@ let(:expected_args) { ['--force-color'] } let(:expected_format) { 'json' } - it_behaves_like 'generate_json_report runs RSpec::Core::Runner' + it_behaves_like 'calculate runs RSpec::Core::Runner' end context 'with --no-color and --force-color' do @@ -145,17 +134,17 @@ expect do KnapsackPro.logger = ::Logger.new($stdout) - subject_class.new.generate_json_report(rspec_args) + subject_class.new.calculate(rspec_args) end .to output(/Please only use one of `--force-color` and `--no-color`/).to_stderr - .and output(%r{ERROR -- : \[knapsack_pro\] Failed to generate the slow test files report: bundle exec rspec --no-color --force-color --format json --dry-run --out .knapsack_pro/test_case_detectors/rspec/rspec_dry_run_json_report_node_0.json --default-path spec spec/a_spec.rb}).to_stdout + .and output(%r{ERROR -- : \[knapsack_pro\] Failed to calculate Split by Test Examples: bundle exec rspec --no-color --force-color --format json --dry-run --out .knapsack_pro/test_case_detectors/rspec/rspec_dry_run_json_report_node_0.json --default-path spec spec/a_spec.rb}).to_stdout .and raise_error(SystemExit) { |error| expect(error.status).to eq 1 } end end end - describe '#test_file_example_paths' do - subject { described_class.new.test_file_example_paths } + describe '#slow_id_paths!' do + subject { described_class.new.slow_id_paths! } context 'when JSON report exists' do it do @@ -170,8 +159,8 @@ expect(File).to receive(:read).with(report_path).and_return(json_file) expect(subject).to eq([ - { 'path' => 'spec/a_spec.rb[1:1]' }, - { 'path' => 'spec/a_spec.rb[1:2]' }, + 'spec/a_spec.rb[1:1]', + 'spec/a_spec.rb[1:2]' ]) end end @@ -186,7 +175,7 @@ end describe '#slow_test_files' do - subject { described_class.new.slow_test_files } + subject { described_class.new.send(:slow_test_files) } before do expect(KnapsackPro::Config::Env).to receive(:slow_test_file_pattern).and_return(slow_test_file_pattern) @@ -208,7 +197,7 @@ it do expected_slow_test_files = double - expect(KnapsackPro::SlowTestFileDeterminer).to receive(:read_from_json_report).and_return(expected_slow_test_files) + expect(KnapsackPro::SlowTestFileFinder).to receive(:call).and_return(expected_slow_test_files) expect(subject).to eq expected_slow_test_files end diff --git a/spec/knapsack_pro/test_file_finder_spec.rb b/spec/knapsack_pro/test_file_finder_spec.rb index eb4cd4fe..26b8543f 100644 --- a/spec/knapsack_pro/test_file_finder_spec.rb +++ b/spec/knapsack_pro/test_file_finder_spec.rb @@ -66,9 +66,9 @@ let(:adapter_class) { double } let(:test_file_entities_to_run) do [ - { 'path' => 'a_spec.rb' }, - { 'path' => 'b_spec.rb' }, - { 'path' => 'not_existing_on_disk_spec.rb' }, + { 'path' => 'a_spec.rb', 'time_execution' => nil }, + { 'path' => 'b_spec.rb', 'time_execution' => 2.2 }, + { 'path' => 'not_existing_on_disk_spec.rb', 'time_execution' => 1.1 }, ] end # test files existing on disk @@ -88,8 +88,8 @@ expect(described_class).to receive(:call).with(test_file_pattern).and_return(test_file_entities) expect(subject).to eq([ - { 'path' => 'a_spec.rb' }, - { 'path' => 'b_spec.rb' }, + { 'path' => 'a_spec.rb', 'time_execution' => nil }, + { 'path' => 'b_spec.rb', 'time_execution' => 2.2 }, ]) end end