crystalball 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +18 -0
  5. data/.travis.yml +20 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +674 -0
  9. data/README.md +196 -0
  10. data/Rakefile +8 -0
  11. data/bin/console +15 -0
  12. data/bin/crystalball +5 -0
  13. data/bin/setup +8 -0
  14. data/crystalball.gemspec +48 -0
  15. data/lib/crystalball.rb +38 -0
  16. data/lib/crystalball/case_map.rb +19 -0
  17. data/lib/crystalball/execution_map.rb +55 -0
  18. data/lib/crystalball/git_repo.rb +58 -0
  19. data/lib/crystalball/map_generator.rb +81 -0
  20. data/lib/crystalball/map_generator/allocated_objects_strategy.rb +44 -0
  21. data/lib/crystalball/map_generator/allocated_objects_strategy/object_tracker.rb +44 -0
  22. data/lib/crystalball/map_generator/base_strategy.rb +21 -0
  23. data/lib/crystalball/map_generator/configuration.rb +54 -0
  24. data/lib/crystalball/map_generator/coverage_strategy.rb +34 -0
  25. data/lib/crystalball/map_generator/coverage_strategy/execution_detector.rb +23 -0
  26. data/lib/crystalball/map_generator/described_class_strategy.rb +35 -0
  27. data/lib/crystalball/map_generator/helpers/path_filter.rb +25 -0
  28. data/lib/crystalball/map_generator/object_sources_detector.rb +54 -0
  29. data/lib/crystalball/map_generator/object_sources_detector/definition_tracer.rb +39 -0
  30. data/lib/crystalball/map_generator/object_sources_detector/hierarchy_fetcher.rb +36 -0
  31. data/lib/crystalball/map_generator/strategies_collection.rb +43 -0
  32. data/lib/crystalball/map_storage/yaml_storage.rb +68 -0
  33. data/lib/crystalball/prediction.rb +34 -0
  34. data/lib/crystalball/predictor.rb +56 -0
  35. data/lib/crystalball/predictor/associated_specs.rb +40 -0
  36. data/lib/crystalball/predictor/modified_execution_paths.rb +21 -0
  37. data/lib/crystalball/predictor/modified_specs.rb +27 -0
  38. data/lib/crystalball/predictor_evaluator.rb +55 -0
  39. data/lib/crystalball/rails.rb +10 -0
  40. data/lib/crystalball/rails/map_generator/action_view_strategy.rb +46 -0
  41. data/lib/crystalball/rails/map_generator/action_view_strategy/patch.rb +42 -0
  42. data/lib/crystalball/rails/map_generator/i18n_strategy.rb +47 -0
  43. data/lib/crystalball/rails/map_generator/i18n_strategy/simple_patch.rb +88 -0
  44. data/lib/crystalball/rspec/prediction_builder.rb +43 -0
  45. data/lib/crystalball/rspec/runner.rb +95 -0
  46. data/lib/crystalball/rspec/runner/configuration.rb +70 -0
  47. data/lib/crystalball/simple_predictor.rb +18 -0
  48. data/lib/crystalball/source_diff.rb +37 -0
  49. data/lib/crystalball/source_diff/file_diff.rb +53 -0
  50. data/lib/crystalball/version.rb +5 -0
  51. metadata +263 -0
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # Crystalball
2
+
3
+ Crystalball is a Ruby library which implements [Regression Test Selection mechanism](https://tenderlovemaking.com/2015/02/13/predicting-test-failues.html) originally published by Aaron Patterson. Its main purpose is to select a subset of your test suite which should be run to ensure your changes didn't break anything.
4
+
5
+ [![Build Status](https://travis-ci.org/toptal/crystalball.svg?branch=master)](https://travis-ci.org/toptal/crystalball)
6
+ [![Maintainability](https://api.codeclimate.com/v1/badges/c8bfc25a43a1a2ecf964/maintainability)](https://codeclimate.com/github/toptal/crystalball/maintainability)
7
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/c8bfc25a43a1a2ecf964/test_coverage)](https://codeclimate.com/github/toptal/crystalball/test_coverage)
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ group :test do
15
+ gem 'crystalball'
16
+ end
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ $ bundle
22
+
23
+ Or install it yourself as:
24
+
25
+ $ gem install crystalball
26
+
27
+ ## Usage
28
+
29
+ 1. Start MapGenerator in your `spec_helper` before you loaded any file of your app. E.g.
30
+ ```ruby
31
+ Crystalball::MapGenerator.start! do |config|
32
+ config.register Crystalball::MapGenerator::CoverageStrategy.new
33
+ end
34
+ ```
35
+ 1. Run your test suite on clean branch with green build. This step will generate file `execution_map.yml` in your project root
36
+ 1. Make some changes to your app code
37
+ 1. Run `bundle exec crystalball` to build a prediction and run RSpec with it. Check out [RSpec runner section](#rspec-runner) for customization details.
38
+
39
+ Keep in mind that as your target branch (usually master) code changes your execution maps will become outdated,
40
+ so you need to regenerate execution maps regularly.
41
+
42
+ ## Map Generator
43
+
44
+ There are different map generator strategies that can (and should) be used together for better predictions. Each one has its own benefits and drawbacks, so they should be configured to best fit your needs.
45
+
46
+ ### CoverageStrategy
47
+
48
+ Uses coverage information to detect which files are covered by the given spec (i.e. the files that, if changed, may potentially break the spec);
49
+ To customize the way the execution detection works, pass an object that responds to #detect and returns the paths to the strategy initialization:
50
+
51
+ ```ruby
52
+ # ...
53
+ config.register Crystalball::MapGenerator::CoverageStrategy.new(MyDetector)
54
+ ```
55
+
56
+ By default, the execution detector is a `Crystalball::MapGenerator::CoverageStrategy::ExecutionDetector`, which filters out the paths outside the root and converts absolute paths to relative.
57
+
58
+ ### AllocatedObjectsStrategy
59
+
60
+ Looks for the files in which the objects allocated during the spec execution are defined. It is considerably slower than `CoverageStrategy`.
61
+ To use this strategy, use the convenient method `.build` which takes two optional keyword arguments: `only`, used to define the classes or modules to have their descendants tracked (defaults to `[]`); and `root`, which is the path where the detection will take place (defaults to `Dir.pwd`).
62
+ Here's an example that tracks allocation of `ActiveRecord::Base` objects:
63
+
64
+ ```ruby
65
+ # ...
66
+ config.register Crystalball::MapGenerator::AllocatedObjectsStrategy.build(only: ['ActiveRecord::Base'])
67
+ ```
68
+
69
+ That method is fine for most uses, but if you need to further customize the behavior of the strategy, you can directly instantiate the class.
70
+
71
+ ```ruby
72
+ # ...
73
+ config.register Crystalball::MapGenerator::AllocatedObjectsStrategy
74
+ .new(execution_detector: MyCustomDetector, object_tracker: MyCustomTracker)
75
+ ```
76
+
77
+ The initialization takes two keyword arguments: `execution_detector` and `object_tracker`.
78
+ `execution_detector` must be an object that responds to `#detect` receiving a list of objects and returning the paths affected by said objects. `object_tracker` is something that responds to `#used_classes_during` which yields to the caller and returns the array of classes of objects allocated during the execution of the block.
79
+
80
+ ### DescribedClassStrategy
81
+
82
+ This strategy will take each example that has a `described_class` (i.e. examples inside `describe` blocks of classes and not strings) and add the paths where the described class and its ancestors are defined to the case map of the example;
83
+
84
+ To use it, add to your `Crystalball::MapGenerator.start!` block:
85
+
86
+ ```ruby
87
+ # ...
88
+ config.register Crystalball::MapGenerator::DescribedClassStrategy.new
89
+ ```
90
+
91
+ As with `AllocatedObjectsStrategy`, you can pass a custom execution detector (an object that responds to `#detect` and returns the paths) to the initialization:
92
+
93
+ ```ruby
94
+ # ...
95
+ config.register Crystalball::MapGenerator::DescribedClassStrategy.new(MyDetector)
96
+ ```
97
+
98
+ ### Rails specific strategies
99
+
100
+ To use Rails specific strategies you must first `require 'crystalball/rails'`.
101
+
102
+ #### ActionViewStrategy
103
+
104
+ This strategy patches `ActionView::Template#compile!` to map the examples to affected views. Use it as follows:
105
+
106
+ ```ruby
107
+ # ...
108
+ config.register Crystalball::MapGenerator::ActionViewStrategy.new
109
+ ```
110
+
111
+ #### I18nStrategy
112
+
113
+ Patches I18n to have access to the path where the locales are defined, so that those paths can be added to the case map.
114
+ To use it, add to your config:
115
+
116
+ ```ruby
117
+ # ...
118
+ config.register Crystalball::MapGenerator::I18nStrategy.new
119
+ ```
120
+
121
+ ### Custom strategies
122
+
123
+ You can create your own strategy and use it with the map generator. Any object that responds to `#call(case_map, example)` (where `case_map` is a `Crystalball::CaseMap` and `example` a `RSpec::Core::Example`) and augmenting its list of affected files using `case_map.push(*paths_to_files)`.
124
+ Check out the [implementation](https://github.com/toptal/crystalball/tree/master/lib/crystalball/map_generator) of the default strategies for details.
125
+
126
+ Keep in mind that all the strategies configured for the map generator will run for each example of your test suite, so it may slow down the generation process considerably.
127
+
128
+ ## Predictor
129
+
130
+ The predictor can also be customized with different strategies:
131
+
132
+ ### AssociatedSpecs
133
+
134
+ Needs to be configured with rules for detecting which specs should be on the prediction.
135
+ `predictor.use Crystalball::Predictor::AssociatedSpecs.new(from: %r{models/(.*).rb}, to: "./spec/models/%s_spec.rb")`
136
+ will add `./spec/models/foo_spec.rb` to prediction when `models/foo.rb` changes.
137
+ This strategy does not depend on a previoulsy generated case map.
138
+
139
+ ### ModifiedExecutionPaths
140
+
141
+ Checks the case map and the diff to see which specs are affected by the new or modified files.
142
+
143
+ ### ModifiedSpecs
144
+
145
+ As the name implies, checks for modified specs. The scope can be modified by passing a regex as argument, which defaults to `%r{spec/.*_spec\.rb\z}`.
146
+ This strategy does not depend on a previously generated case map.
147
+
148
+ ### Custom strategies
149
+
150
+ As with the map generator you may define custom strategies for prediction. It must be an object that responds to `#call(diff, case_map)` (where `diff` is a `Crystalball::SourceDiff` and `case_map` is a `Crystalball::CaseMap`) and returns an array of paths.
151
+
152
+ Check out [default strategies implementation](https://github.com/toptal/crystalball/tree/master/lib/crystalball/predictor) for details.
153
+
154
+ ## Under the hood
155
+
156
+ TODO: Write good description for anyone who wants to customize behavior
157
+
158
+ ## Spring integration
159
+
160
+ It's very easy to integrate Crystalball with [Spring](https://github.com/rails/spring). Check out [spring-commands-crystalball](https://github.com/pluff/spring-commands-crystalball) for details.
161
+
162
+ ## Plans
163
+
164
+ 1. RSpec parallel integration
165
+ 1. Map size optimization
166
+ 1. Different strategies for execution map
167
+ 1. Different strategies for failure predictor
168
+ 1. Integration for git hook
169
+
170
+ ## RSpec Runner
171
+
172
+ There is a custom RSpec runner you can use in your development with `bundle exec crystalball` command. It builds a prediction and runs it.
173
+
174
+ ### Runner Configuration
175
+
176
+ #### Config file
177
+
178
+ Create a YAML file for the runner. Default locations are `./crystalball.yml` and `./config/crystalball.yml`. You can override config path with `CRYSTALBALL_CONFIG` env variable.
179
+ Please check an [example of a config file](https://github.com/toptal/crystalball/blob/master/spec/fixtures/crystalball.yml) for available options
180
+
181
+ #### Environment variables
182
+
183
+ `CRYSTALBALL_CONFIG=path/to/crystalball.yml` if you want to override default path to config file.
184
+ `CRYSTALBALL_SKIP_MAP_CHECK=true` if you want to skip maps expiration period check.
185
+ `CRYSTALBALL_SKIP_EXAMPLES_LIMIT=true` if you want to skip examples limit check.
186
+
187
+ ## Development
188
+
189
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
190
+
191
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
192
+
193
+ ## Contributing
194
+
195
+ Bug reports and pull requests are welcome on GitHub at https://github.com/toptal/crystalball. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
196
+
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "crystalball"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/crystalball ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'crystalball'
5
+ Crystalball::RSpec::Runner.invoke
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'crystalball/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "crystalball"
9
+ spec.version = Crystalball::VERSION
10
+ spec.authors = ["Pavel Shutsin"]
11
+ spec.email = ["publicshady@gmail.com"]
12
+
13
+ spec.summary = 'A library for RSpec regression test selection'
14
+ spec.description = 'Provides simple way to integrate regression test selection approach to your RSpec test suite'
15
+ spec.homepage = 'https://github.com/toptal/crystalball'
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
21
+ else
22
+ raise "RubyGems 2.0 or newer is required to protect against " \
23
+ "public gem pushes."
24
+ end
25
+
26
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
27
+ f.match(%r{^(test|spec|features)/})
28
+ end
29
+ spec.bindir = "bin"
30
+ spec.executables = [File.basename('bin/crystalball')]
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency 'git'
34
+
35
+ spec.required_ruby_version = '> 2.3.0'
36
+
37
+ spec.add_development_dependency 'actionview'
38
+ spec.add_development_dependency "bundler", "~> 1.14"
39
+ spec.add_development_dependency 'i18n'
40
+ spec.add_development_dependency 'pry'
41
+ spec.add_development_dependency 'pry-byebug'
42
+ spec.add_development_dependency "rake", "~> 10.0"
43
+ spec.add_development_dependency "rspec", "~> 3.0"
44
+ spec.add_development_dependency 'rubocop'
45
+ spec.add_development_dependency 'rubocop-rspec'
46
+ spec.add_development_dependency 'simplecov'
47
+ spec.add_development_dependency 'yard'
48
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'crystalball/git_repo'
4
+ require 'crystalball/rspec/prediction_builder'
5
+ require 'crystalball/rspec/runner'
6
+ require 'crystalball/prediction'
7
+ require 'crystalball/predictor'
8
+ require 'crystalball/predictor/modified_execution_paths'
9
+ require 'crystalball/predictor/modified_specs'
10
+ require 'crystalball/predictor/associated_specs'
11
+ require 'crystalball/case_map'
12
+ require 'crystalball/execution_map'
13
+ require 'crystalball/map_generator'
14
+ require 'crystalball/map_generator/configuration'
15
+ require 'crystalball/map_generator/coverage_strategy'
16
+ require 'crystalball/map_generator/allocated_objects_strategy'
17
+ require 'crystalball/map_generator/described_class_strategy'
18
+ require 'crystalball/map_storage/yaml_storage'
19
+ require 'crystalball/version'
20
+
21
+ # Main module for the library
22
+ module Crystalball
23
+ # Prints the list of specs which might fail
24
+ #
25
+ # @param [String] workdir - path to the root directory of repository (usually contains .git folder inside). Default: current directory
26
+ # @param [String] map_path - path to the execution map. Default: execution_map.yml
27
+ # @param [Proc] block - used to configure predictors
28
+ #
29
+ # @example
30
+ # Crystalball.foresee do |predictor|
31
+ # predictor.use Crystalball::Predictor::ModifiedExecutionPaths.new
32
+ # predictor.use Crystalball::Predictor::ModifiedSpecs.new
33
+ # end
34
+ def self.foresee(workdir: '.', map_path: 'execution_map.yml', &block)
35
+ map = MapStorage::YAMLStorage.load(Pathname(map_path))
36
+ Predictor.new(map, GitRepo.open(Pathname(workdir)), from: map.commit, &block).prediction.compact
37
+ end
38
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ # Data object to store execution map for specific example
5
+ class CaseMap
6
+ attr_reader :uid, :file_path, :affected_files
7
+ extend Forwardable
8
+
9
+ delegate %i[push] => :affected_files
10
+
11
+ # @param [String] example - id of example
12
+ # @param [Array<String>] affected_files - list of files affected by example
13
+ def initialize(example, affected_files = [])
14
+ @uid = example.id
15
+ @file_path = example.file_path
16
+ @affected_files = affected_files
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystalball
4
+ # Storage for execution map
5
+ class ExecutionMap
6
+ extend Forwardable
7
+
8
+ # Simple data object for map metadata information
9
+ class Metadata
10
+ attr_accessor :commit, :type, :version
11
+
12
+ # @param [String] commit - SHA of commit
13
+ # @param [String] type - type of execution map
14
+ # @param [Numeric] version - map generator version number
15
+ def initialize(commit: nil, type: nil, version: nil)
16
+ @commit = commit
17
+ @type = type
18
+ @version = version
19
+ end
20
+
21
+ def to_h
22
+ {type: type, commit: commit, version: version}
23
+ end
24
+ end
25
+
26
+ attr_reader :cases, :metadata
27
+
28
+ delegate %i[commit commit= version version=] => :metadata
29
+ delegate %i[size] => :cases
30
+
31
+ # @param [Hash] metadata - add or override metadata of execution map
32
+ # @param [Hash] cases - initial list of cases
33
+ def initialize(metadata: {}, cases: {})
34
+ @cases = cases
35
+
36
+ @metadata = Metadata.new(type: self.class.name, **metadata)
37
+ end
38
+
39
+ # Adds case map to the list
40
+ #
41
+ # @param [Crystalball::CaseMap] case_map
42
+ def <<(case_map)
43
+ cases[case_map.uid] = case_map.affected_files.uniq
44
+ end
45
+
46
+ # Remove all cases
47
+ def clear!
48
+ self.cases = {}
49
+ end
50
+
51
+ private
52
+
53
+ attr_writer :cases, :metadata
54
+ end
55
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git'
4
+ require 'crystalball/source_diff'
5
+
6
+ module Crystalball
7
+ # Wrapper class representing Git repository
8
+ class GitRepo
9
+ attr_reader :repo_path
10
+
11
+ class << self
12
+ # @return [Crystalball::GitRepo] instance for given path
13
+ def open(repo_path)
14
+ path = Pathname(repo_path)
15
+ new(path) if exists?(path)
16
+ end
17
+
18
+ # Check if given path is under git control (contains .git folder)
19
+ def exists?(path)
20
+ path.join('.git').directory?
21
+ end
22
+ end
23
+
24
+ # @param [Pathname] repo_path path to repository root folder
25
+ def initialize(repo_path)
26
+ @repo_path = repo_path
27
+ end
28
+
29
+ # Check if repository has no uncommitted changes
30
+ def pristine?
31
+ diff.empty?
32
+ end
33
+
34
+ # Proxy all unknown calls to `Git` object
35
+ def method_missing(method, *args, &block)
36
+ repo.public_send(method, *args, &block) || super
37
+ end
38
+
39
+ def respond_to_missing?(method, *)
40
+ repo.respond_to?(method, false) || super
41
+ end
42
+
43
+ # Creates diff
44
+ #
45
+ # @param [String] from starting commit to build a diff. Default: HEAD
46
+ # @param [String] to ending commit to build a diff. Default: nil, will build diff of uncommitted changes
47
+ # @return [SourceDiff]
48
+ def diff(from = 'HEAD', to = nil)
49
+ SourceDiff.new(repo.diff(from, to))
50
+ end
51
+
52
+ private
53
+
54
+ def repo
55
+ @repo ||= Git.open(repo_path)
56
+ end
57
+ end
58
+ end