gitlab-crystalball 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/README.md +56 -0
  4. data/bin/crystalball +5 -0
  5. data/lib/crystalball/active_record.rb +4 -0
  6. data/lib/crystalball/example_group_map.rb +19 -0
  7. data/lib/crystalball/execution_map.rb +56 -0
  8. data/lib/crystalball/extensions/git/base.rb +14 -0
  9. data/lib/crystalball/extensions/git/lib.rb +17 -0
  10. data/lib/crystalball/extensions/git.rb +4 -0
  11. data/lib/crystalball/factory_bot.rb +3 -0
  12. data/lib/crystalball/git_repo.rb +53 -0
  13. data/lib/crystalball/logging.rb +51 -0
  14. data/lib/crystalball/map_compactor/example_context.rb +29 -0
  15. data/lib/crystalball/map_compactor/example_groups_data_compactor.rb +66 -0
  16. data/lib/crystalball/map_compactor.rb +44 -0
  17. data/lib/crystalball/map_generator/allocated_objects_strategy/object_tracker.rb +45 -0
  18. data/lib/crystalball/map_generator/allocated_objects_strategy.rb +45 -0
  19. data/lib/crystalball/map_generator/base_strategy.rb +22 -0
  20. data/lib/crystalball/map_generator/configuration.rb +58 -0
  21. data/lib/crystalball/map_generator/coverage_strategy/execution_detector.rb +23 -0
  22. data/lib/crystalball/map_generator/coverage_strategy.rb +35 -0
  23. data/lib/crystalball/map_generator/described_class_strategy.rb +35 -0
  24. data/lib/crystalball/map_generator/factory_bot_strategy/dsl_patch/factory_path_fetcher.rb +30 -0
  25. data/lib/crystalball/map_generator/factory_bot_strategy/dsl_patch.rb +40 -0
  26. data/lib/crystalball/map_generator/factory_bot_strategy/factory_gem_loader.rb +27 -0
  27. data/lib/crystalball/map_generator/factory_bot_strategy/factory_runner_patch.rb +25 -0
  28. data/lib/crystalball/map_generator/factory_bot_strategy.rb +59 -0
  29. data/lib/crystalball/map_generator/helpers/path_filter.rb +25 -0
  30. data/lib/crystalball/map_generator/object_sources_detector/definition_tracer.rb +39 -0
  31. data/lib/crystalball/map_generator/object_sources_detector/hierarchy_fetcher.rb +36 -0
  32. data/lib/crystalball/map_generator/object_sources_detector.rb +54 -0
  33. data/lib/crystalball/map_generator/parser_strategy/processor.rb +129 -0
  34. data/lib/crystalball/map_generator/parser_strategy.rb +60 -0
  35. data/lib/crystalball/map_generator/strategies_collection.rb +43 -0
  36. data/lib/crystalball/map_generator.rb +84 -0
  37. data/lib/crystalball/map_storage/yaml_storage.rb +69 -0
  38. data/lib/crystalball/prediction.rb +35 -0
  39. data/lib/crystalball/predictor/associated_specs.rb +45 -0
  40. data/lib/crystalball/predictor/helpers/affected_example_groups_detector.rb +20 -0
  41. data/lib/crystalball/predictor/helpers/path_formatter.rb +18 -0
  42. data/lib/crystalball/predictor/modified_execution_paths.rb +27 -0
  43. data/lib/crystalball/predictor/modified_specs.rb +33 -0
  44. data/lib/crystalball/predictor/modified_support_specs.rb +39 -0
  45. data/lib/crystalball/predictor/strategy.rb +16 -0
  46. data/lib/crystalball/predictor.rb +58 -0
  47. data/lib/crystalball/predictor_evaluator.rb +55 -0
  48. data/lib/crystalball/rails/helpers/base_schema_parser.rb +51 -0
  49. data/lib/crystalball/rails/helpers/schema_definition_parser/active_record.rb +21 -0
  50. data/lib/crystalball/rails/helpers/schema_definition_parser/table_content_parser.rb +27 -0
  51. data/lib/crystalball/rails/helpers/schema_definition_parser.rb +36 -0
  52. data/lib/crystalball/rails/map_generator/action_view_strategy/patch.rb +42 -0
  53. data/lib/crystalball/rails/map_generator/action_view_strategy.rb +46 -0
  54. data/lib/crystalball/rails/map_generator/i18n_strategy/simple_patch.rb +89 -0
  55. data/lib/crystalball/rails/map_generator/i18n_strategy.rb +47 -0
  56. data/lib/crystalball/rails/predictor/modified_schema.rb +81 -0
  57. data/lib/crystalball/rails/tables_map.rb +53 -0
  58. data/lib/crystalball/rails/tables_map_generator/configuration.rb +39 -0
  59. data/lib/crystalball/rails/tables_map_generator.rb +84 -0
  60. data/lib/crystalball/rails.rb +11 -0
  61. data/lib/crystalball/rspec/filtering.rb +52 -0
  62. data/lib/crystalball/rspec/prediction_builder.rb +53 -0
  63. data/lib/crystalball/rspec/prediction_pruning/examples_pruner.rb +70 -0
  64. data/lib/crystalball/rspec/prediction_pruning.rb +56 -0
  65. data/lib/crystalball/rspec/runner/configuration.rb +80 -0
  66. data/lib/crystalball/rspec/runner.rb +107 -0
  67. data/lib/crystalball/rspec/standard_prediction_builder.rb +17 -0
  68. data/lib/crystalball/source_diff/file_diff.rb +53 -0
  69. data/lib/crystalball/source_diff/formatting_checker.rb +50 -0
  70. data/lib/crystalball/source_diff.rb +48 -0
  71. data/lib/crystalball/version.rb +5 -0
  72. data/lib/crystalball.rb +44 -0
  73. metadata +314 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/map_generator/base_strategy"
4
+ require "crystalball/map_generator/helpers/path_filter"
5
+ require "crystalball/rails/map_generator/action_view_strategy/patch"
6
+
7
+ module Crystalball
8
+ module Rails
9
+ class MapGenerator
10
+ # Map generator strategy to build map of views affected by an example.
11
+ # It patches `ActionView::Template#compile!` to get original name of compiled views.
12
+ class ActionViewStrategy
13
+ include ::Crystalball::MapGenerator::BaseStrategy
14
+ include ::Crystalball::MapGenerator::Helpers::PathFilter
15
+
16
+ class << self
17
+ # List of views affected by current example
18
+ #
19
+ # @return [Array<String>]
20
+ attr_reader :views
21
+
22
+ # Reset cached list of views
23
+ def reset_views
24
+ @views = []
25
+ end
26
+ end
27
+
28
+ def after_start
29
+ Patch.apply!
30
+ end
31
+
32
+ def before_finalize
33
+ Patch.revert!
34
+ end
35
+
36
+ # Adds views related to the spec to the example group map
37
+ # @param [Crystalball::ExampleGroupMap] example_group_map - object holding example metadata and used files
38
+ def call(example_group_map, _)
39
+ self.class.reset_views
40
+ yield example_group_map
41
+ example_group_map.push(*filter(self.class.views))
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ module Crystalball
6
+ module Rails
7
+ class MapGenerator
8
+ class I18nStrategy
9
+ # Module to add new patched `load_file`, `store_translations` and `lookup`
10
+ # methods to `I18n::Backend::Simple`.
11
+ module SimplePatch
12
+ class << self
13
+ # Patches `I18n::Backend::Simple`.
14
+ def apply!
15
+ ::I18n::Backend::Simple.class_eval do
16
+ include SimplePatch
17
+
18
+ %i[load_file store_translations lookup].each do |method|
19
+ alias_method :"cb_original_#{method}", method
20
+ alias_method method, :"cb_patched_#{method}"
21
+ end
22
+ end
23
+ end
24
+
25
+ # Reverts original behavior of `I18n::Backend::Simple`
26
+ def revert!
27
+ ::I18n::Backend::Simple.class_eval do
28
+ %i[load_file store_translations lookup].each do |method|
29
+ alias_method method, :"cb_original_#{method}"
30
+ undef_method :"cb_patched_#{method}"
31
+ end
32
+ end
33
+ ::I18n.reload!
34
+ end
35
+ end
36
+
37
+ # Will replace original `I18n::Backend::Simple#load_file`.
38
+ # Stores filename in current thread
39
+ def cb_patched_load_file(filename, *args)
40
+ Thread.current[:cb_locale_file_name] = filename
41
+ cb_original_load_file(filename, *args)
42
+ end
43
+
44
+ # Will replace original `I18n::Backend::Simple#store_translations`.
45
+ # Adds filename for each value
46
+ def cb_patched_store_translations(locale, data, *args)
47
+ cb_add_filename_to_values(data, Thread.current[:cb_locale_file_name])
48
+ cb_original_store_translations(locale, data, *args)
49
+ end
50
+
51
+ # Will replace original `I18n::Backend::Simple#lookup`.
52
+ # Records origin filename of each value used.
53
+ def cb_patched_lookup(*args)
54
+ value = cb_original_lookup(*args)
55
+ cb_remove_and_track_filename_from_values(value)
56
+ end
57
+
58
+ private
59
+
60
+ def cb_add_filename_to_values(data, filename)
61
+ data.each do |key, value|
62
+ case value
63
+ when Hash
64
+ next if value.frozen?
65
+
66
+ cb_add_filename_to_values(value, filename)
67
+ else
68
+ data[key] = {cb_filename: filename, cb_value: value}.freeze
69
+ end
70
+ end
71
+ end
72
+
73
+ def cb_remove_and_track_filename_from_values(data)
74
+ return data unless data.is_a?(Hash)
75
+
76
+ if data.key?(:cb_filename)
77
+ ::Crystalball::Rails::MapGenerator::I18nStrategy.locale_files << data[:cb_filename]
78
+ return data[:cb_value]
79
+ end
80
+
81
+ data.each.with_object({}) do |(key, value), collector|
82
+ collector[key] = cb_remove_and_track_filename_from_values(value)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/map_generator/base_strategy"
4
+ require "crystalball/map_generator/helpers/path_filter"
5
+ require "crystalball/rails/map_generator/i18n_strategy/simple_patch"
6
+
7
+ module Crystalball
8
+ module Rails
9
+ class MapGenerator
10
+ # Map generator strategy to build map of locale files used by an example.
11
+ class I18nStrategy
12
+ include ::Crystalball::MapGenerator::BaseStrategy
13
+ include ::Crystalball::MapGenerator::Helpers::PathFilter
14
+
15
+ class << self
16
+ # List of locale files affected by current example
17
+ #
18
+ # @return [Array<String>]
19
+ def locale_files
20
+ @locale_files ||= []
21
+ end
22
+
23
+ # Reset cached list of locale files
24
+ def reset_locale_files
25
+ @locale_files = []
26
+ end
27
+ end
28
+
29
+ def after_register
30
+ SimplePatch.apply!
31
+ end
32
+
33
+ def before_finalize
34
+ SimplePatch.revert!
35
+ end
36
+
37
+ # Adds to the example group map the locale files used by the example
38
+ # @param [Crystalball::ExampleGroupMap] example_group_map - object holding example metadata and used files
39
+ def call(example_group_map, _)
40
+ self.class.reset_locale_files
41
+ yield example_group_map
42
+ example_group_map.push(*filter(self.class.locale_files.compact))
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/rails/helpers/schema_definition_parser"
4
+ require "crystalball/predictor/helpers/affected_example_groups_detector"
5
+
6
+ module Crystalball
7
+ module Rails
8
+ class Predictor
9
+ # Used with `predictor.use Crystalball::Rails::Predictor::ModifiedSchema.new(tables_map_path:)`.
10
+ # When used will check db/schema.rb for changes and add specs which depend on files affected
11
+ # by changed tables
12
+ class ModifiedSchema
13
+ include ::Crystalball::Predictor::Helpers::AffectedExampleGroupsDetector
14
+ SCHEMA_PATH = "db/schema.rb"
15
+
16
+ attr_reader :tables_map_path
17
+
18
+ # @param [String] tables_map_path - path to generated TablesMap
19
+ def initialize(tables_map_path:)
20
+ @tables_map_path = tables_map_path
21
+ end
22
+
23
+ # @param [Crystalball::SourceDiff] diff - the diff from which to predict
24
+ # which specs should run
25
+ # @param [Crystalball::ExecutionMap] map - the map with the relations of
26
+ # examples and used files
27
+ # @return [Array<String>] the spec paths associated with the changes
28
+ def call(diff, map)
29
+ return [] if schema_diff(diff).nil?
30
+
31
+ old_schema = old_schema(diff)
32
+ new_schema = new_schema(diff)
33
+
34
+ changed_tables = changed_tables(old_schema, new_schema)
35
+
36
+ files = changed_tables.flat_map do |table_name|
37
+ files = tables_map[table_name]
38
+ Crystalball.log :warn, "There are no model files for changed table `#{table_name}`. Check https://github.com/toptal/crystalball#warning for detailed description" unless files&.any?
39
+ files
40
+ end.compact
41
+ detect_examples(files, map)
42
+ end
43
+
44
+ # @return [Crystalball::Rails::TablesMap]
45
+ def tables_map
46
+ @tables_map ||= MapStorage::YAMLStorage.load(Pathname(tables_map_path))
47
+ end
48
+
49
+ private
50
+
51
+ def schema_diff(diff)
52
+ diff.find { |file_diff| [SCHEMA_PATH, "./#{SCHEMA_PATH}"].include? file_diff.relative_path }
53
+ end
54
+
55
+ def old_schema(diff)
56
+ old_schema_contents = schema_content(diff.repository, diff.from)
57
+ Crystalball::Rails::Helpers::SchemaDefinitionParser.parse(old_schema_contents)
58
+ end
59
+
60
+ def new_schema(diff)
61
+ new_schema_contents = schema_content(diff.repository, diff.to)
62
+ Crystalball::Rails::Helpers::SchemaDefinitionParser.parse(new_schema_contents)
63
+ end
64
+
65
+ def schema_content(repository, revision)
66
+ if revision
67
+ repository.lib.show(revision, SCHEMA_PATH)
68
+ else
69
+ File.read(File.join(repository.dir.path, SCHEMA_PATH))
70
+ end
71
+ end
72
+
73
+ def changed_tables(schema1, schema2)
74
+ schema1.map do |table_name, body|
75
+ table_name if schema2[table_name] != body
76
+ end.compact
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ module Rails
5
+ # Storage for tables map
6
+ class TablesMap
7
+ extend Forwardable
8
+
9
+ # Simple data object for map metadata information
10
+ class Metadata
11
+ attr_reader :commit, :version
12
+
13
+ # @param [String] commit - SHA of commit
14
+ # @param [Numeric] version - map generator version number
15
+ def initialize(commit: nil, version: nil, **_)
16
+ @commit = commit
17
+ @version = version
18
+ end
19
+
20
+ def to_h
21
+ {type: TablesMap.name, commit: commit, version: version}
22
+ end
23
+ end
24
+
25
+ attr_reader :example_groups, :metadata
26
+
27
+ delegate %i[commit version] => :metadata
28
+ delegate %i[size [] []=] => :example_groups
29
+
30
+ # @param [Hash] metadata - add or override metadata of execution map
31
+ # @param [Hash] example_groups - initial list of tables
32
+ def initialize(metadata: {}, example_groups: {})
33
+ @metadata = Metadata.new(**metadata)
34
+ @example_groups = example_groups
35
+ end
36
+
37
+ # Remove all example_groups
38
+ def clear!
39
+ self.example_groups = {}
40
+ end
41
+
42
+ def add(files:, for_table:)
43
+ example_groups[for_table] ||= []
44
+ example_groups[for_table] += files
45
+ example_groups[for_table].uniq!
46
+ end
47
+
48
+ private
49
+
50
+ attr_writer :example_groups, :metadata
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/map_generator/object_sources_detector"
4
+
5
+ module Crystalball
6
+ module Rails
7
+ class TablesMapGenerator
8
+ # Configuration of tables map generator. Is can be accessed as a first argument inside
9
+ # `Crystalball::Rails::TablesMapGenerator.start! { |config| config } block.
10
+ class Configuration
11
+ attr_writer :map_storage
12
+ attr_accessor :commit
13
+ attr_accessor :version
14
+ attr_writer :root_path
15
+ attr_writer :object_sources_detector
16
+
17
+ def map_storage_path
18
+ @map_storage_path ||= Pathname("tables_map.yml")
19
+ end
20
+
21
+ def map_storage_path=(value)
22
+ @map_storage_path = Pathname(value)
23
+ end
24
+
25
+ def map_storage
26
+ @map_storage ||= MapStorage::YAMLStorage.new(map_storage_path)
27
+ end
28
+
29
+ def root_path
30
+ @root_path ||= Dir.pwd
31
+ end
32
+
33
+ def object_sources_detector
34
+ @object_sources_detector ||= ::Crystalball::MapGenerator::ObjectSourcesDetector.new(root_path: root_path)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/rails/tables_map"
4
+ require "crystalball/rails/tables_map_generator/configuration"
5
+
6
+ module Crystalball
7
+ module Rails
8
+ # Class to generate tables to files map during RSpec build execution
9
+ class TablesMapGenerator
10
+ extend Forwardable
11
+
12
+ attr_reader :configuration
13
+ delegate %i[map_storage object_sources_detector] => :configuration
14
+
15
+ class << self
16
+ # Registers Crystalball handlers to generate execution map during specs execution
17
+ #
18
+ # @param [Proc] block to configure MapGenerator and Register strategies
19
+ def start!(&block)
20
+ generator = new(&block)
21
+
22
+ ::RSpec.configure do |c|
23
+ c.before(:suite) { generator.start! }
24
+ c.after(:suite) { generator.finalize! }
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize
30
+ @configuration = Configuration.new
31
+ @configuration.commit = repo.object("HEAD").sha if repo
32
+ yield @configuration if block_given?
33
+ object_sources_detector.after_register
34
+ end
35
+
36
+ # Prepares metadata for execution map
37
+ def start!
38
+ self.map = nil
39
+ map_storage.clear!
40
+
41
+ map_storage.dump(map.metadata.to_h)
42
+
43
+ self.started = true
44
+ end
45
+
46
+ # Finalizes and saves map
47
+ def finalize!
48
+ return unless started
49
+
50
+ collect_tables_info
51
+
52
+ object_sources_detector.before_finalize
53
+ map_storage.dump(map.example_groups) if map.size.positive?
54
+ end
55
+
56
+ # @return [Crystalball::Rails::TablesMap]
57
+ def map
58
+ @map ||= TablesMap.new(metadata: {commit: configuration.commit, version: configuration.version})
59
+ end
60
+
61
+ private
62
+
63
+ def repo
64
+ @repo = GitRepo.open(".") unless defined?(@repo)
65
+ @repo
66
+ end
67
+
68
+ def collect_tables_info
69
+ ActiveRecord::Base.descendants.each do |descendant|
70
+ table_name = descendant.table_name
71
+
72
+ next if table_name.nil?
73
+
74
+ files = object_sources_detector.detect([descendant])
75
+
76
+ map.add(files: files, for_table: table_name)
77
+ end
78
+ end
79
+
80
+ attr_writer :map
81
+ attr_accessor :started
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/rails/map_generator/action_view_strategy"
4
+ require "crystalball/rails/map_generator/i18n_strategy"
5
+ require "crystalball/active_record"
6
+
7
+ module Crystalball
8
+ # Module containting Rails-specific stuff for Crystalball
9
+ module Rails
10
+ end
11
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ module RSpec
5
+ # This class is meant to remove the example filtering options
6
+ # for example_groups when a prediction contains a file path and the same file
7
+ # example id.
8
+ #
9
+ # For example, if a prediction contains `./spec/foo_spec.rb[1:1] ./spec/foo_spec.rb`,
10
+ # only `./spec/foo_spec.rb[1:1]` would run, because of the way RSpec
11
+ # filters are designed.
12
+ #
13
+ # Therefore, we need to manually remove the filters from such example_groups.
14
+ class Filtering
15
+ # @param [RSpec::Core::Configuration] config
16
+ # @param [Array<String>] paths
17
+ def self.remove_unnecessary_filters(config, paths)
18
+ new(config).remove_unnecessary_filters(paths)
19
+ end
20
+
21
+ def initialize(configuration)
22
+ @configuration = configuration
23
+ end
24
+
25
+ def remove_unnecessary_filters(files_or_directories)
26
+ directories, files = files_or_directories.partition { |f| File.directory?(f) }
27
+ remove_unecessary_filters_from_files(files)
28
+ remove_unecessary_filters_from_directories(directories)
29
+ end
30
+
31
+ def remove_unecessary_filters_from_directories(directories)
32
+ directories.each do |dir|
33
+ files = configuration.__send__(:gather_directories, dir)
34
+ remove_unecessary_filters_from_files(files)
35
+ end
36
+ end
37
+
38
+ def remove_unecessary_filters_from_files(files)
39
+ files.select { |f| ::RSpec::Core::Example.parse_id(f).last.nil? }.each do |file|
40
+ next remove_unecessary_filters(fd) if File.directory?(file)
41
+
42
+ path = ::RSpec::Core::Metadata.relative_path(File.expand_path(file))
43
+ configuration.filter_manager.inclusions[:ids]&.delete(path)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :configuration
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Crystalball
6
+ module RSpec
7
+ # Class for building a prediction for RSpec runner.
8
+ # Accepts configuration hash and builds a prediction according to configuration.
9
+ class PredictionBuilder
10
+ attr_reader :config
11
+
12
+ def initialize(config = {})
13
+ @config = config
14
+ end
15
+
16
+ def prediction
17
+ predictor.prediction
18
+ end
19
+
20
+ def expired_map?
21
+ expiration_period = config["map_expiration_period"].to_i
22
+ return false unless expiration_period.positive?
23
+
24
+ execution_map.timestamp.to_i <= Time.now.to_i - config["map_expiration_period"]
25
+ end
26
+
27
+ def execution_map
28
+ @execution_map ||= Crystalball::MapStorage::YAMLStorage.load(config["execution_map_path"])
29
+ end
30
+
31
+ def repo
32
+ @repo ||= Crystalball::GitRepo.open(config["repo_path"])
33
+ end
34
+
35
+ private
36
+
37
+ # This method should be overridden in ancestor. Example:
38
+ #
39
+ # def predictor
40
+ # super do |p|
41
+ # p.use Crystalball::Predictor::ModifiedExecutionPaths.new
42
+ # p.use Crystalball::Predictor::ModifiedSpecs.new
43
+ # end
44
+ # end
45
+ #
46
+ def predictor(&block)
47
+ raise NotImplementedError, "Configure `prediction_builder_class_name` in `crystalball.yml` and override `predictor` method" unless block_given?
48
+
49
+ @predictor ||= Crystalball::Predictor.new(execution_map, repo, from: config["diff_from"], to: config["diff_to"], &block)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ module RSpec
5
+ module PredictionPruning
6
+ # A class to prune given world example groups to fit the limit.
7
+ class ExamplesPruner
8
+ # Simple data object for holding context ids array with total examples size
9
+ class ContextIdsSet
10
+ attr_reader :ids, :size
11
+ alias to_a ids
12
+
13
+ def initialize
14
+ @size = 0
15
+ @ids = []
16
+ end
17
+
18
+ def add(id, size = 1)
19
+ @size += size
20
+ @ids << id
21
+ end
22
+ end
23
+
24
+ attr_reader :world, :limit
25
+
26
+ # @param [RSpec::Core::World] rspec_world RSpec world instance
27
+ # @param [Integer] to upper bound limit for prediction.
28
+ def initialize(rspec_world, to:)
29
+ @world = rspec_world
30
+ @limit = to
31
+ end
32
+
33
+ # @return [Array<String>] set of example and context ids to run
34
+ def pruned_set
35
+ resulting_set = ContextIdsSet.new
36
+ world.ordered_example_groups.each { |g| prune_to_limit(g, resulting_set) }
37
+ resulting_set.to_a
38
+ end
39
+
40
+ private
41
+
42
+ def prune_to_limit(group, resulting_set)
43
+ return if resulting_set.size >= limit
44
+
45
+ group_size = world.example_count([group])
46
+
47
+ if resulting_set.size + group_size > limit
48
+ (group.descendants - [group]).each do |g|
49
+ prune_to_limit(g, resulting_set)
50
+ end
51
+
52
+ add_examples(group, resulting_set)
53
+ else
54
+ resulting_set.add(group.id, group_size)
55
+ end
56
+ end
57
+
58
+ def add_examples(group, resulting_set)
59
+ limit_diff = limit - resulting_set.size
60
+
61
+ return unless limit_diff.positive?
62
+
63
+ group.filtered_examples.first(limit_diff).each do |example|
64
+ resulting_set.add(example.id)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crystalball/rspec/prediction_pruning/examples_pruner"
4
+
5
+ module Crystalball
6
+ module RSpec
7
+ # Module contains logic related to examples_limit configuration option for our runner.
8
+ module PredictionPruning
9
+ def self.included(base)
10
+ base.extend ClassMethods
11
+ end
12
+
13
+ # Class methods for prediction pruning logic
14
+ module ClassMethods
15
+ def examples_limit
16
+ config["examples_limit"].to_i
17
+ end
18
+
19
+ private
20
+
21
+ def prune_prediction_to_limit(prediction)
22
+ return prediction if !examples_limit.positive? || prediction.size <= examples_limit
23
+
24
+ Crystalball.log :warn, "Prediction size #{prediction.size} is over the limit (#{examples_limit})"
25
+ Crystalball.log :warn, "Prediction is pruned to fit the limit!"
26
+
27
+ # Actual examples size is not less than prediction size.
28
+ prediction.first(examples_limit)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def examples_limit
35
+ self.class.examples_limit
36
+ end
37
+
38
+ def reconfiguration_needed?
39
+ examples_limit.positive? && @world.example_count > examples_limit
40
+ end
41
+
42
+ def reconfigure_to_limit
43
+ pruner = ExamplesPruner.new(@world, to: examples_limit)
44
+
45
+ @options = ::RSpec::Core::ConfigurationOptions.new(pruner.pruned_set)
46
+ @world.reset
47
+ @world.filtered_examples.clear
48
+ @world.instance_variable_get(:@example_group_counts_by_spec_file).clear
49
+ @configuration.reset
50
+ @configuration.reset_filters
51
+
52
+ @options.configure(@configuration)
53
+ end
54
+ end
55
+ end
56
+ end