flakey_spec_catcher 0.3.0

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.
@@ -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: []