flakey_spec_catcher 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 27339d7daba0c74bb7d57aec6a18685f1aa6034f11158a99e055e971eaebebe4
4
+ data.tar.gz: cc738a7d26cc97a63dec0becca3cc9a13244c4cae9906a5645174d8277357f84
5
+ SHA512:
6
+ metadata.gz: cad188a9621417d13d104d0b26fe379e875daa2c4c571e64d6429acb04bd20898e8e727724e2916506e33d6a5bca8f9ca27175f3b154eebb5e3c096c92aaa7ce
7
+ data.tar.gz: 17dcab2f7900196b7bc388ab6e37e86fbbd394b4cffd5d8a29fa7f751b61b55ce58523f59420c3f5ac9c3c2c706a7ceed6fd003aebb17a320551c578233cabff
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'flakey_spec_catcher'
5
+
6
+ app = FlakeySpecCatcher::Runner.new
7
+ app.show_settings
8
+ app.rerun_preview
9
+ exit app.run_specs
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ autoload :VERSION, 'flakey_spec_catcher/version'
5
+ autoload :Runner, 'flakey_spec_catcher/runner'
6
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # CapsuleManager class
5
+ #
6
+ # Contains a file by file summary of all git changes.
7
+ #
8
+ # A CapsuleManager object contains all ChangeCapsule objects. It delivers
9
+ # summaries of all changes and runs methods on the contained ChangeCapsule
10
+ # objects.
11
+ class CapsuleManager
12
+ attr_reader :change_capsules
13
+
14
+ def initialize
15
+ @change_capsules = []
16
+ end
17
+
18
+ def add_capsule(capsule)
19
+ @change_capsules.push(capsule)
20
+ end
21
+
22
+ def changed_examples
23
+ @change_capsules.map(&:changed_examples).flatten.uniq
24
+ end
25
+
26
+ def changed_files
27
+ @change_capsules.map(&:file_name).uniq
28
+ end
29
+
30
+ # rubocop:disable Metrics/AbcSize
31
+ def condense_reruns
32
+ # Don't re-run if the parent context of a change will be run
33
+ reruns = []
34
+ all_contexts = @change_capsules.map(&:change_contexts).flatten
35
+ .sort_by { |c| c.line_number.to_i }
36
+
37
+ all_contexts.each do |context|
38
+ next if reruns.include?(context.file_name)
39
+
40
+ found_parent_match = false
41
+ all_contexts.each do |other|
42
+ next if context == other
43
+
44
+ found_parent_match = true if context.parent_matches_context(other)
45
+ end
46
+
47
+ next if found_parent_match
48
+
49
+ reruns.push(context.rerun_info) unless reruns.include?(context.rerun_info)
50
+ end
51
+ reruns
52
+ end
53
+ # rubocop:enable Metrics/AbcSize
54
+ end
55
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # ChangeCapsule class
5
+ #
6
+ # Summarizes a git change for a single file.
7
+ #
8
+ # A ChangeCapsule object will represent the changes made to a single file. It
9
+ # accomplishes this using ChangeContext and ChangeSummary objects.
10
+ class ChangeCapsule
11
+ attr_reader :file_name, :change_summary, :change_contexts
12
+ SCOPE_SPECIFIERS = %w[it context describe scenario].freeze
13
+ SHARED_EXAMPLES = %w[include_examples it_behaves_like it_should_behave_like matching].freeze
14
+
15
+ def initialize(file_name, change_summary, change_contexts = [])
16
+ @file_name = file_name
17
+ @change_summary = change_summary
18
+ @change_contexts = []
19
+ handle_initial_change_contexts(change_contexts)
20
+ end
21
+
22
+ def changed_examples
23
+ @change_contexts.map(&:rerun_info)
24
+ end
25
+
26
+ def fill_contexts
27
+ change_context_stack = []
28
+ ignore_scope_closure = 0
29
+ lines_in_file = File.read(@file_name).split("\n")
30
+ lines_in_file.each_with_index do |line, index|
31
+ if line =~ spec_scope
32
+ handle_change_context(line, index, change_context_stack)
33
+ elsif line =~ /\s*do(\s+|$)/
34
+ ignore_scope_closure += 1
35
+ end
36
+
37
+ fill_context(line, index, change_context_stack)
38
+
39
+ # Note - some things use do-like loops and we need to be able to ignore those
40
+ if line =~ pop_scope
41
+ if ignore_scope_closure.positive?
42
+ ignore_scope_closure -= 1
43
+ else
44
+ change_context_stack.pop
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def handle_initial_change_contexts(change_contexts)
53
+ change_contexts.each do |context|
54
+ add_change_context(context)
55
+ end
56
+ end
57
+
58
+ def add_change_context(context)
59
+ if context.nil?
60
+ change_context = FlakeySpecCatcher::ChangeContext.new(description: nil,
61
+ line_number: nil,
62
+ parent_description: nil,
63
+ parent_line_number: nil,
64
+ file_name: @file_name)
65
+ @change_contexts.push(change_context)
66
+ else
67
+ @change_contexts.push(context) unless @change_contexts.any? { |c| c == context }
68
+ end
69
+ end
70
+
71
+ def spec_scope
72
+ # Not sure if we need to check for description in quotes
73
+ # spec_scope = /^\s*(#{SCOPE_SPECIFIERS.join("|")})\s*('.*'|".*").*do\s*$/
74
+
75
+ /\s*(#{SCOPE_SPECIFIERS.join("|")}).*\s+do\s*$/
76
+ end
77
+
78
+ def pop_scope
79
+ /\s+end(\s+|$)/
80
+ end
81
+
82
+ def fill_context(line, index, change_context_stack)
83
+ return unless line_is_inside_a_block_of_changed_code?(index)
84
+ return if line_is_a_comment_or_whitespace?(line)
85
+
86
+ add_change_context(change_context_stack[-1])
87
+ end
88
+
89
+ def line_is_inside_a_block_of_changed_code?(index)
90
+ changed_line_start = @change_summary.working_commit_line_number
91
+ changed_line_count = @change_summary.working_commit_lines_altered
92
+ changed_line_end = changed_line_start + changed_line_count
93
+ changed_line_start == (index + 1) ||
94
+ (changed_line_start...changed_line_end).cover?(index + 1)
95
+ end
96
+
97
+ def line_is_a_comment_or_whitespace?(line)
98
+ line =~ /^((\s*)?|(\s*#.*))$/
99
+ end
100
+
101
+ def handle_change_context(line, line_number, change_context_stack)
102
+ if !change_context_stack.empty?
103
+ parent_desc = change_context_stack[-1].description
104
+ parent_line = change_context_stack[-1].line_number
105
+ else
106
+ parent_desc = nil
107
+ parent_line = nil
108
+ end
109
+ change_context = FlakeySpecCatcher::ChangeContext.new(
110
+ description: line, line_number: (line_number + 1), parent_description: parent_desc,
111
+ parent_line_number: parent_line, file_name: @file_name
112
+ )
113
+
114
+ change_context_stack.push(change_context) unless
115
+ change_context_stack.any? { |c| c == change_context }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # ChangeContext class
5
+ #
6
+ # Identifies the conditions in which a code change occurs.
7
+ #
8
+ # At a high level, a ChangeContext captures (A) the line number, and (B) the
9
+ # block of code in which a change exists. This identifies both the code
10
+ # change's context and its parent context.
11
+ #
12
+ # FSC uses parent contexts to eliminate re-running tests too many times.
13
+ # For example, if changes are detected in both a 'describe' block and a child
14
+ # test case inside of that 'describe' block, FSC will re-run the contents of
15
+ # the 'describe' block only.
16
+ class ChangeContext
17
+ attr_reader :description, :line_number
18
+ attr_reader :parent_description, :parent_line_number
19
+ attr_reader :file_name
20
+
21
+ def initialize(description:, line_number:, parent_description:,
22
+ parent_line_number:, file_name:)
23
+ @description = description
24
+ @line_number = line_number
25
+ @parent_description = parent_description
26
+ @parent_line_number = parent_line_number
27
+ @file_name = file_name
28
+ update_descriptions
29
+ end
30
+
31
+ def update_descriptions
32
+ if @description.nil? || @description.empty?
33
+ @description = @file_name
34
+ @line_number = nil
35
+ @parent_description = nil
36
+ @parent_line_number = nil
37
+ elsif @parent_description.nil? || @parent_description.empty?
38
+ @parent_description = @file_name
39
+ @parent_line_number = nil
40
+ end
41
+ end
42
+
43
+ def ==(other)
44
+ @description == other.description &&
45
+ @line_number == other.line_number &&
46
+ @file_name == other.file_name
47
+ end
48
+
49
+ def rerun_info
50
+ if @line_number.nil?
51
+ @file_name
52
+ else
53
+ "#{@file_name}:#{@line_number}"
54
+ end
55
+ end
56
+
57
+ def parent_matches_context(other)
58
+ @parent_description == other.description &&
59
+ @parent_line_number == other.line_number &&
60
+ @file_name == other.file_name
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # ChangeSummary class
5
+ #
6
+ # Takes in a git diff block summary and converts it to an object.
7
+ #
8
+ # This class converts a block of changes (retrieved through use of git diff)
9
+ # into an object that will allow the created ChangeCapsule objects to more
10
+ # easily find and identify changes.
11
+ class ChangeSummary
12
+ attr_reader :master_commit_line_number, :master_commit_lines_altered
13
+ attr_reader :working_commit_line_number, :working_commit_lines_altered
14
+ attr_reader :change_type
15
+
16
+ def initialize(change_summary)
17
+ @master_commit_line_number, @master_commit_lines_altered =
18
+ change_summary.split[0].delete('-').split(',').map(&:to_i)
19
+ @master_commit_lines_altered ||= 0
20
+ @working_commit_line_number, @working_commit_lines_altered =
21
+ change_summary.split[1].delete('+').split(',').map(&:to_i)
22
+ @working_commit_lines_altered ||= 0
23
+ initialize_change_type
24
+ end
25
+
26
+ private
27
+
28
+ def initialize_change_type
29
+ @change_type = if @working_commit_lines_altered >= @master_commit_lines_altered
30
+ 'ADD'
31
+ else
32
+ 'REMOVE'
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './change_capsule'
4
+ require_relative './change_summary'
5
+ require_relative './change_context'
6
+ require_relative './capsule_manager'
7
+
8
+ module FlakeySpecCatcher
9
+ # GitController class
10
+ #
11
+ # Runs a 'git diff' between the local working commit and a base commit that
12
+ # represents what is at the top of the source control manager.
13
+ #
14
+ # The end goal of the GitController is to specify which tests have changed in
15
+ # a form that can be easily sent to RSpec. Because you can specify a test to
16
+ # run via `rspec <path_to_spec_file>:<file number>` the GitController will
17
+ # summarize changes in that form.
18
+ class GitController
19
+ attr_reader :branch, :remote, :working_commit_sha, :base_commit_sha, :git_comparison
20
+ attr_reader :capsule_manager
21
+
22
+ def initialize(test_mode: false, capsule_manager: FlakeySpecCatcher::CapsuleManager.new)
23
+ @branch = `git branch | grep '*'`.delete('*').gsub(/\s+/, '')
24
+ @remote = `git remote`.gsub(/\s+/, '')
25
+ @capsule_manager = capsule_manager
26
+ initialize_git_comparison(test_mode)
27
+ parse_changes
28
+ identify_change_contexts
29
+ end
30
+
31
+ def changed_examples
32
+ @capsule_manager.change_capsules.map(&:changed_examples).flatten.uniq
33
+ end
34
+
35
+ private
36
+
37
+ def initialize_git_comparison(test_mode)
38
+ if !test_mode
39
+ @working_commit_sha = `git rev-parse @`.gsub(/\s+/, '')
40
+ @base_commit_sha = `git rev-parse #{@remote}/#{@branch}`.gsub(/\s+/, '')
41
+ else
42
+ @working_commit_sha = `git rev-parse HEAD`.gsub(/\s+/, '')
43
+ @base_commit_sha = `git rev-parse HEAD~1`.gsub(/\s+/, '')
44
+ end
45
+ @git_comparison = "#{@base_commit_sha}..#{@working_commit_sha}"
46
+ end
47
+
48
+ def parse_changes
49
+ # For each file, get the change block
50
+ diff_files.each do |filename|
51
+ next unless filename =~ /_spec.rb/
52
+
53
+ # Get diff pairs [ /path/to/file, "@@ -19 +19,4 @@" ]
54
+ diff = `git diff --unified=0 #{@git_comparison} #{filename}`
55
+ diff_pairs = diff.split("\n").select { |line| line =~ /^@@/ || line =~ /^diff --git/ }
56
+
57
+ diff_pairs.each do |line|
58
+ next unless (diff_summary = /-\d+,?(\d+)?\s\+\d+,?(\d+)?/.match(line))
59
+
60
+ change_summary = ChangeSummary.new(diff_summary.to_s)
61
+ capsule = ChangeCapsule.new(filename, change_summary)
62
+ @capsule_manager.add_capsule(capsule)
63
+ end
64
+ end
65
+ end
66
+
67
+ def identify_change_contexts
68
+ @capsule_manager.change_capsules.each(&:fill_contexts)
69
+ end
70
+
71
+ def diff_files
72
+ `git diff --name-only #{@git_comparison}`.gsub("\n", ',').split(',')
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # RerunCapsule class
5
+ #
6
+ # Contains one file or test case to re-run, as well as its associated RSpec
7
+ # usage.
8
+ class RerunCapsule
9
+ include Comparable
10
+ attr_reader :usage, :testcase
11
+
12
+ def initialize(usage: nil, testcase: '')
13
+ @usage = initialize_usage(usage)
14
+ @testcase = initialize_testcase(testcase)
15
+ end
16
+
17
+ def empty?
18
+ if testcase.empty?
19
+ true
20
+ else
21
+ false
22
+ end
23
+ end
24
+
25
+ def default_usage?
26
+ @usage.nil?
27
+ end
28
+
29
+ def <=>(other)
30
+ @testcase <=> other.testcase
31
+ end
32
+
33
+ private
34
+
35
+ def initialize_usage(usage)
36
+ if usage.nil? || usage.empty?
37
+ nil
38
+ else
39
+ usage
40
+ end
41
+ end
42
+
43
+ def initialize_testcase(testcase)
44
+ if testcase.nil? || testcase.empty?
45
+ ''
46
+ else
47
+ testcase
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './rerun_capsule'
4
+
5
+ module FlakeySpecCatcher
6
+ # RerunManager class
7
+ #
8
+ # Pairs file changes with user-specified re-run commands. Those pairs are
9
+ # defined via environment variables.
10
+ class RerunManager
11
+ attr_reader :rerun_capsules
12
+
13
+ def initialize(git_controller: FlakeySpecCatcher::GitController.new,
14
+ user_config: FlakeySpecCatcher::UserConfig.new)
15
+
16
+ @git_controller = git_controller
17
+ @user_config = user_config
18
+ @rerun_capsules = []
19
+ pair_reruns_with_usages
20
+ end
21
+
22
+ def tests_for_rerun
23
+ tests = if @user_config.rerun_file_only
24
+ @git_controller.capsule_manager.changed_files
25
+ else
26
+ @git_controller.capsule_manager.condense_reruns
27
+ end
28
+ filter_reruns_by_ignore_files(tests)
29
+ end
30
+
31
+ def pair_reruns_with_usages
32
+ reruns = tests_for_rerun
33
+ configured_usage_patterns = @user_config.rspec_usage_patterns
34
+
35
+ if configured_usage_patterns.count.zero?
36
+ add_capsules_with_default_usage(reruns)
37
+ return
38
+ end
39
+
40
+ reruns.each do |rerun|
41
+ match_found = false
42
+ configured_usage_patterns.each do |usage_pattern_pair|
43
+ next if match_found
44
+
45
+ pattern, usage = usage_pattern_pair
46
+ if rerun =~ /#{pattern}/
47
+ add_rerun_capsule(testcase: rerun, usage: usage)
48
+ match_found = true
49
+ end
50
+ end
51
+ add_rerun_capsule(testcase: rerun) unless match_found
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def filter_reruns_by_ignore_files(reruns)
58
+ return reruns if @user_config.ignore_files.count.zero?
59
+
60
+ filtered_reruns = []
61
+ ignore_files = "(#{@user_config.ignore_files.join('|')})"
62
+ ignore_files_regex = /#{ignore_files}/
63
+
64
+ reruns.each do |file|
65
+ next if file =~ ignore_files_regex
66
+
67
+ filtered_reruns.push(file)
68
+ end
69
+ filtered_reruns
70
+ end
71
+
72
+ def add_capsules_with_default_usage(reruns)
73
+ reruns.each do |rerun|
74
+ @rerun_capsules.push(FlakeySpecCatcher::RerunCapsule.new(testcase: rerun))
75
+ end
76
+ end
77
+
78
+ def add_rerun_capsule(testcase: '', usage: nil)
79
+ @rerun_capsules.push(FlakeySpecCatcher::RerunCapsule.new(testcase: testcase, usage: usage))
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # RspecResult class
5
+ #
6
+ # Organizes results for a changed test (spec).
7
+ #
8
+ # Each changed test will have one RspecResult created for it and for each
9
+ # re-run of that spec, the results will be pushed to the RspecResult object.
10
+ # This class will then organize and output the results accordingly.
11
+ class RspecResult
12
+ attr_reader :file_name, :total_examples_run, :total_failures, :failure_summary
13
+
14
+ def initialize(file_name)
15
+ @file_name = file_name
16
+ @total_examples_run = 0
17
+ @total_failures = 0
18
+ @failure_summary = []
19
+ end
20
+
21
+ def info
22
+ ratio = "#{@total_examples_run - @total_failures}/#{@total_examples_run}"
23
+ puts "\nFile Name: #{@file_name} passed #{ratio} examples"
24
+
25
+ return unless @total_failures.positive?
26
+
27
+ puts ' Failure Summary:'
28
+ @failure_summary.each do |example, _occurrences|
29
+ example.info
30
+ end
31
+ end
32
+
33
+ def add_run(results)
34
+ parse_totals(results)
35
+
36
+ # Parse the failed examples and add them into the failure_summary
37
+ if @failure_summary.empty?
38
+ # Add the initial runs
39
+ @failure_summary = parse_failed_examples(results)
40
+ else
41
+ populate_failure_summary(parse_failed_examples(results))
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def parse_failed_examples(results)
48
+ failures = []
49
+ # Currently we're only going to return the name of the example that failed
50
+ # Example:
51
+ # 1) Flakey spec Passes some of the time
52
+ # Failure/Error: expect(1).to eq(rand(3))
53
+ #
54
+ # expected: 0
55
+ # got: 1
56
+ #
57
+ # (compared using ==)
58
+ # ./spec/flakey_example_spec.rb:6:in `block (2 levels) in <top (required)>'
59
+
60
+ # Would return 'Flakey spec Passes some of the time'
61
+
62
+ # Get the line that contains the failed example - Denoted by '1)' in the examples
63
+ # and strip the example ennumerators
64
+ example_lines = results.scan(/^\s*\d\).*$/)
65
+ failed_examples = example_lines.map { |example| example.strip.sub!(/\d\)\s*/, '') }
66
+
67
+ # Use the first line of the failed_examples to get the actual failures
68
+ failed_examples.each do |example|
69
+ # Go from the spec example description until '#)' or 'Finished in'
70
+ result = results[/#{Regexp.escape(example)}(.*?)(Finished in| \d\))/m, 1]
71
+ failures << RSpecFailure.new(example, result.strip)
72
+ end
73
+
74
+ # Return the array of RSpecFailures
75
+ failures
76
+ end
77
+
78
+ def parse_totals(results)
79
+ # Get summary line to determine number of run results.
80
+ # Will appear as "3 examples, 0 failures"
81
+ summary = results.split("\n").find { |line| line =~ /example.*failure/ }
82
+ pass_count, fail_count = summary.split(' ').select { |x| x[/\d/] }
83
+ @total_examples_run += pass_count.to_i
84
+ @total_failures += fail_count.to_i
85
+ end
86
+
87
+ def populate_failure_summary(failures)
88
+ # For each failure, if that example is in @failure_summary, add the results to it
89
+ failures.each do |failure|
90
+ match_found = false
91
+ @failure_summary.each do |example|
92
+ # If example is already in the summary, add the expect/actual to the example's failures
93
+ if example.example_name == failure.example_name
94
+ example.add_case(failure.failure_details.join(''))
95
+ match_found = true
96
+ end
97
+ end
98
+ # If example is not in the summary, add the whole object to the array of examples
99
+ @failure_summary << failure unless match_found
100
+ end
101
+ end
102
+ end
103
+
104
+ # Simple class to contain failed example data
105
+ class RSpecFailure
106
+ attr_reader :example_name, :failure_details
107
+
108
+ def initialize(example_name, results)
109
+ @example_name = example_name
110
+ @failure_count = 1
111
+ @failure_details = [results]
112
+ end
113
+
114
+ def add_case(results)
115
+ @failure_count += 1
116
+ @failure_details << results
117
+ end
118
+
119
+ def info
120
+ puts "\n '#{@example_name}' failed #{@failure_count} time(s)"
121
+ @failure_details.each { |failure| puts "\n #{failure}" }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec'
4
+ require_relative './rspec_result'
5
+ require_relative './git_controller'
6
+ require_relative './capsule_manager'
7
+ require_relative './user_config'
8
+ require_relative './rerun_manager'
9
+
10
+ module FlakeySpecCatcher
11
+ class Runner
12
+ attr_reader :user_config, :rerun_manager, :git_controller
13
+ attr_reader :rspec_results
14
+
15
+ def initialize(test_mode: false,
16
+ git_controller: FlakeySpecCatcher::GitController.new(test_mode: test_mode),
17
+ user_config: FlakeySpecCatcher::UserConfig.new,
18
+ rerun_manager: FlakeySpecCatcher::RerunManager.new(git_controller: git_controller,
19
+ user_config: user_config))
20
+
21
+ @rspec_results = []
22
+ @git_controller = git_controller
23
+ @user_config = user_config
24
+ @rerun_manager = rerun_manager
25
+ end
26
+
27
+ # Debug Methods
28
+ def show_settings
29
+ puts 'Flakey Spec Catcher Settings:'
30
+ puts " Current Branch: #{@git_controller.branch}"
31
+ puts " Remote: #{@git_controller.remote}"
32
+ puts " Current Sha: #{@git_controller.working_commit_sha}"
33
+ puts " Base Sha: #{@git_controller.base_commit_sha}"
34
+ puts " Repeat factor: #{@user_config.repeat_factor}"
35
+ puts " Changed Specs Detected: #{@git_controller.changed_examples}"
36
+ end
37
+
38
+ def rerun_preview
39
+ puts "\n********************************************"
40
+ puts "Re-run Preview\n"
41
+ @rerun_manager.rerun_capsules.sort.each do |capsule|
42
+ rerun_msg = " Running #{capsule.testcase} #{@user_config.repeat_factor} times "
43
+ rerun_msg += "using `#{capsule.usage}`" unless capsule.default_usage?
44
+ puts rerun_msg
45
+ end
46
+ puts "\n********************************************"
47
+ end
48
+ # end Debug methods
49
+
50
+ def run_specs
51
+ status = 0
52
+ @user_config.repeat_factor.times do
53
+ @rerun_manager.rerun_capsules.sort.each do |capsule|
54
+ iteration_status = 0
55
+ if capsule.default_usage?
56
+ iteration_status = invoke_rspec_runner(capsule.testcase)
57
+ else
58
+ `#{capsule.usage} #{capsule.testcase}`
59
+ iteration_status = $?.exitstatus # rubocop:disable Style/SpecialGlobalVars
60
+ end
61
+ status = [status, iteration_status].max
62
+ end
63
+ end
64
+
65
+ # Always return 0 if silent_mode is enabled
66
+ if @user_config.silent_mode
67
+ 0
68
+ else
69
+ status
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def invoke_rspec_runner(test)
76
+ return_status = RSpec::Core::Runner.run([test])
77
+ RSpec.clear_examples
78
+ return_status
79
+ end
80
+
81
+ def print_rspec_results
82
+ @rspec_results.map(&:get_info)
83
+
84
+ # Determine the exit code based on the parsed results
85
+ if @rspec_results.any? { |r| r.total_failures.positive? }
86
+ puts "\n********************************************"
87
+ puts 'Flakiness Detected! Exiting with status code 1'
88
+ puts "********************************************\n"
89
+ return 1
90
+ else
91
+ puts "\n***********************************************"
92
+ puts 'No Flakiness Detected! Exiting with status code 0'
93
+ puts "***********************************************\n"
94
+ return 0
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ # UserConfig class
5
+ #
6
+ # Captures user-defined settings to configure RSpec re-run settings.
7
+ class UserConfig
8
+ attr_reader :repeat_factor, :ignore_files, :ignore_branches, :silent_mode
9
+ attr_reader :rerun_file_only, :rspec_usage_patterns
10
+ USER_CONFIG_ENV_VARS = %w[FSC_REPEAT_FACTOR FSC_IGNORE_FILES FSC_IGNORE_BRANCHES
11
+ FSC_SILENT_MODE FSC_RERUN_FILE_ONLY FSC_USAGE_PATTERNS].freeze
12
+
13
+ def initialize
14
+ @repeat_factor = initialize_repeat_factor(ENV['FSC_REPEAT_FACTOR'])
15
+ @ignore_files = env_var_string_to_array(ENV['FSC_IGNORE_FILES'])
16
+ @ignore_branches = env_var_string_to_array(ENV['FSC_IGNORE_BRANCHES'])
17
+ @silent_mode = env_var_string_to_bool(ENV['FSC_SILENT_MODE'])
18
+ @rerun_file_only = env_var_string_to_bool(ENV['FSC_RERUN_FILE_ONLY'])
19
+ @rspec_usage_patterns = env_var_string_to_pairs(ENV['FSC_USAGE_PATTERNS'])
20
+ parse_commit_message
21
+ end
22
+
23
+ private
24
+
25
+ def initialize_repeat_factor(env_var)
26
+ env_var.to_i.positive? ? env_var.to_i : 20
27
+ end
28
+
29
+ def env_var_string_to_array(env_var)
30
+ if env_var.nil? || env_var.empty?
31
+ []
32
+ else
33
+ env_var.split(',').map(&:strip)
34
+ end
35
+ end
36
+
37
+ def env_var_string_to_bool(env_var)
38
+ if env_var.to_s.casecmp('true').zero?
39
+ true
40
+ else
41
+ false
42
+ end
43
+ end
44
+
45
+ def env_var_string_to_pairs(env_var)
46
+ if env_var.nil? || env_var.empty?
47
+ []
48
+ else
49
+ env_var.scan(/{.*?}/).map do |pair|
50
+ pair.tr('{}', '').split('=>').map(&:strip)
51
+ end
52
+ end
53
+ end
54
+
55
+ def format_regex_scan_results(matches)
56
+ # Scanning the commit message will result in an array of matches
57
+ # based on the specified regex. If the pattern uses groups like ours does
58
+ # then an array of arrays will be returned.
59
+ # See: https://apidock.com/ruby/String/scan
60
+
61
+ # For each array of matches, flatten the individual match group sub-array
62
+ # then remove the formatter quotes we require the values to be wrapped in
63
+ matches.map { |m| m.join('').tr("'", '') }
64
+ end
65
+
66
+ def parse_commit_message
67
+ commit_message = `git show -q`
68
+ USER_CONFIG_ENV_VARS.each do |env_var|
69
+ matches = commit_message.scan(/#{env_var}\s*=\s*('.*?')/)
70
+ next unless matches.count.positive?
71
+
72
+ # In the case of multiple overrides being specified for the same config
73
+ # variable, we'll only use the first
74
+ env_value = format_regex_scan_results(matches).first
75
+ override_user_config(env_var, env_value)
76
+ end
77
+ end
78
+
79
+ # rubocop:disable Metrics/CyclomaticComplexity
80
+ def override_user_config(env_var, env_value)
81
+ case env_var
82
+ when 'FSC_REPEAT_FACTOR'
83
+ @repeat_factor = initialize_repeat_factor(env_value)
84
+ when 'FSC_IGNORE_FILES'
85
+ @ignore_files = env_var_string_to_array(env_value)
86
+ when 'FSC_IGNORE_BRANCHES'
87
+ @ignore_branches = env_var_string_to_array(env_value)
88
+ when 'FSC_SILENT_MODE'
89
+ @silent_mode = env_var_string_to_bool(env_value)
90
+ when 'FSC_RERUN_FILE_ONLY'
91
+ @rerun_file_only = env_var_string_to_bool(env_value)
92
+ when 'FSC_USAGE_PATTERNS'
93
+ @rspec_usage_patterns = env_var_string_to_pairs(env_value)
94
+ end
95
+ end
96
+ # rubocop:enable Metrics/CyclomaticComplexity
97
+ end
98
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlakeySpecCatcher
4
+ VERSION = '0.3.0'
5
+ end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flakey_spec_catcher
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Watson
8
+ - Mikey Hargiss
9
+ - Ben Nelson
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2019-10-10 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rspec
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - "~>"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.8'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '3.8'
29
+ - !ruby/object:Gem::Dependency
30
+ name: climate_control
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - "~>"
34
+ - !ruby/object:Gem::Version
35
+ version: '0.2'
36
+ type: :development
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '0.2'
43
+ - !ruby/object:Gem::Dependency
44
+ name: rake
45
+ requirement: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '12.3'
50
+ type: :development
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - "~>"
55
+ - !ruby/object:Gem::Version
56
+ version: '12.3'
57
+ - !ruby/object:Gem::Dependency
58
+ name: simplecov
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - "~>"
62
+ - !ruby/object:Gem::Version
63
+ version: '0.17'
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.17'
71
+ description:
72
+ email:
73
+ - bwatson@instructure.com
74
+ - mhargiss@instructure.com
75
+ - bnelson@instructure.com
76
+ executables:
77
+ - flakey_spec_catcher
78
+ extensions: []
79
+ extra_rdoc_files: []
80
+ files:
81
+ - bin/flakey_spec_catcher
82
+ - lib/flakey_spec_catcher.rb
83
+ - lib/flakey_spec_catcher/capsule_manager.rb
84
+ - lib/flakey_spec_catcher/change_capsule.rb
85
+ - lib/flakey_spec_catcher/change_context.rb
86
+ - lib/flakey_spec_catcher/change_summary.rb
87
+ - lib/flakey_spec_catcher/git_controller.rb
88
+ - lib/flakey_spec_catcher/rerun_capsule.rb
89
+ - lib/flakey_spec_catcher/rerun_manager.rb
90
+ - lib/flakey_spec_catcher/rspec_result.rb
91
+ - lib/flakey_spec_catcher/runner.rb
92
+ - lib/flakey_spec_catcher/user_config.rb
93
+ - lib/flakey_spec_catcher/version.rb
94
+ homepage:
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ allowed_push_host: https://rubygems.org
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '2.3'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.0.1
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Run new or changed specs many times to prevent unreliable specs
118
+ test_files: []