flakey_spec_catcher 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|