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.
- checksums.yaml +7 -0
- data/bin/flakey_spec_catcher +9 -0
- data/lib/flakey_spec_catcher.rb +6 -0
- data/lib/flakey_spec_catcher/capsule_manager.rb +55 -0
- data/lib/flakey_spec_catcher/change_capsule.rb +118 -0
- data/lib/flakey_spec_catcher/change_context.rb +63 -0
- data/lib/flakey_spec_catcher/change_summary.rb +36 -0
- data/lib/flakey_spec_catcher/git_controller.rb +75 -0
- data/lib/flakey_spec_catcher/rerun_capsule.rb +51 -0
- data/lib/flakey_spec_catcher/rerun_manager.rb +82 -0
- data/lib/flakey_spec_catcher/rspec_result.rb +124 -0
- data/lib/flakey_spec_catcher/runner.rb +98 -0
- data/lib/flakey_spec_catcher/user_config.rb +98 -0
- data/lib/flakey_spec_catcher/version.rb +5 -0
- metadata +118 -0
checksums.yaml
ADDED
@@ -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,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
|
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: []
|