rspec-rewind 0.1.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +42 -124
- data/lib/rspec/rewind/api.rb +37 -0
- data/lib/rspec/rewind/attempt_runner.rb +7 -2
- data/lib/rspec/rewind/backoff.rb +26 -2
- data/lib/rspec/rewind/configuration.rb +176 -30
- data/lib/rspec/rewind/configuration_validation.rb +75 -0
- data/lib/rspec/rewind/core.rb +150 -0
- data/lib/rspec/rewind/event.rb +67 -16
- data/lib/rspec/rewind/example_state_resetter.rb +14 -42
- data/lib/rspec/rewind/failure_fingerprint.rb +19 -0
- data/lib/rspec/rewind/flaky_reporter.rb +18 -16
- data/lib/rspec/rewind/flaky_transition.rb +20 -3
- data/lib/rspec/rewind/matcher_validation.rb +17 -4
- data/lib/rspec/rewind/retry_budget.rb +126 -3
- data/lib/rspec/rewind/retry_count_resolver.rb +26 -3
- data/lib/rspec/rewind/retry_decision.rb +128 -20
- data/lib/rspec/rewind/retry_delay_resolver.rb +57 -6
- data/lib/rspec/rewind/retry_event_builder.rb +73 -6
- data/lib/rspec/rewind/retry_gate.rb +100 -6
- data/lib/rspec/rewind/retry_loop.rb +53 -6
- data/lib/rspec/rewind/retry_notifier.rb +55 -8
- data/lib/rspec/rewind/retry_policy.rb +137 -9
- data/lib/rspec/rewind/retry_summary.rb +61 -0
- data/lib/rspec/rewind/retry_transition.rb +189 -8
- data/lib/rspec/rewind/rspec_adapter.rb +47 -0
- data/lib/rspec/rewind/runner.rb +5 -4
- data/lib/rspec/rewind/runner_component_factory.rb +16 -0
- data/lib/rspec/rewind/runner_components.rb +11 -5
- data/lib/rspec/rewind/version.rb +1 -1
- data/lib/rspec/rewind.rb +2 -65
- data/sig/rspec/rewind.rbs +290 -24
- metadata +8 -1
|
@@ -4,23 +4,58 @@ module RSpec
|
|
|
4
4
|
module Rewind
|
|
5
5
|
class Configuration
|
|
6
6
|
include MatcherValidation
|
|
7
|
+
include ConfigurationValidation
|
|
7
8
|
|
|
8
9
|
attr_reader :default_retries, :backoff, :retry_on, :skip_retry_on, :retry_if, :retry_callback, :flaky_callback,
|
|
9
|
-
:
|
|
10
|
+
:not_retried_callback, :before_retry, :after_retry, :verbose, :display_retry_failure_messages,
|
|
11
|
+
:display_retry_backtrace_top, :clear_lets_on_failure, :retry_budget, :flaky_reporter,
|
|
12
|
+
:flaky_report_path, :retry_if_mode, :retry_on_default, :report_retry_events,
|
|
13
|
+
:strict_callbacks, :strict_callable_arity, :metadata_report_keys, :max_retries,
|
|
14
|
+
:max_elapsed_time, :max_total_sleep, :sleeper, :clock, :dry_run,
|
|
15
|
+
:strict_matcher_validation, :reset_failure_policy, :retry_summary,
|
|
16
|
+
:display_retry_summary, :fail_on_flaky, :max_flaky_examples,
|
|
17
|
+
:freeze_configuration_at_suite_start, :warn_on_delay_conflict,
|
|
18
|
+
:detect_retry_gem_conflicts
|
|
10
19
|
|
|
11
20
|
def initialize
|
|
12
21
|
self.default_retries = 0
|
|
13
22
|
self.backoff = Backoff.fixed(0)
|
|
23
|
+
self.strict_matcher_validation = false
|
|
14
24
|
self.retry_on = []
|
|
15
25
|
self.skip_retry_on = []
|
|
16
26
|
self.retry_if = nil
|
|
17
27
|
self.retry_callback = nil
|
|
18
28
|
self.flaky_callback = nil
|
|
29
|
+
self.not_retried_callback = nil
|
|
30
|
+
self.before_retry = nil
|
|
31
|
+
self.after_retry = nil
|
|
32
|
+
self.retry_summary = RetrySummary.new
|
|
19
33
|
self.verbose = false
|
|
20
34
|
self.display_retry_failure_messages = false
|
|
35
|
+
self.display_retry_backtrace_top = false
|
|
36
|
+
self.display_retry_summary = false
|
|
37
|
+
self.fail_on_flaky = false
|
|
38
|
+
self.max_flaky_examples = nil
|
|
39
|
+
self.freeze_configuration_at_suite_start = false
|
|
40
|
+
self.warn_on_delay_conflict = true
|
|
41
|
+
self.detect_retry_gem_conflicts = true
|
|
21
42
|
self.clear_lets_on_failure = true
|
|
43
|
+
self.reset_failure_policy = :raise
|
|
22
44
|
self.retry_budget = nil
|
|
23
45
|
self.flaky_reporter = FlakyReporter.null
|
|
46
|
+
self.flaky_report_path = nil
|
|
47
|
+
self.retry_if_mode = :override
|
|
48
|
+
self.retry_on_default = :all
|
|
49
|
+
self.report_retry_events = false
|
|
50
|
+
self.strict_callbacks = false
|
|
51
|
+
self.strict_callable_arity = false
|
|
52
|
+
self.metadata_report_keys = []
|
|
53
|
+
self.max_retries = nil
|
|
54
|
+
self.max_elapsed_time = nil
|
|
55
|
+
self.max_total_sleep = nil
|
|
56
|
+
self.sleeper = Kernel.method(:sleep)
|
|
57
|
+
self.clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
58
|
+
self.dry_run = false
|
|
24
59
|
end
|
|
25
60
|
|
|
26
61
|
def default_retries=(value)
|
|
@@ -32,11 +67,19 @@ module RSpec
|
|
|
32
67
|
end
|
|
33
68
|
|
|
34
69
|
def retry_on=(values)
|
|
35
|
-
@retry_on = normalize_matchers(
|
|
70
|
+
@retry_on = normalize_matchers(
|
|
71
|
+
values,
|
|
72
|
+
field: 'retry_on',
|
|
73
|
+
strict_exception_matchers: @strict_matcher_validation
|
|
74
|
+
)
|
|
36
75
|
end
|
|
37
76
|
|
|
38
77
|
def skip_retry_on=(values)
|
|
39
|
-
@skip_retry_on = normalize_matchers(
|
|
78
|
+
@skip_retry_on = normalize_matchers(
|
|
79
|
+
values,
|
|
80
|
+
field: 'skip_retry_on',
|
|
81
|
+
strict_exception_matchers: @strict_matcher_validation
|
|
82
|
+
)
|
|
40
83
|
end
|
|
41
84
|
|
|
42
85
|
def retry_if=(callable)
|
|
@@ -51,6 +94,26 @@ module RSpec
|
|
|
51
94
|
@flaky_callback = normalize_callable(callable, field: 'flaky_callback')
|
|
52
95
|
end
|
|
53
96
|
|
|
97
|
+
def not_retried_callback=(callable)
|
|
98
|
+
@not_retried_callback = normalize_callable(callable, field: 'not_retried_callback')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def before_retry=(callable)
|
|
102
|
+
@before_retry = normalize_callable(callable, field: 'before_retry')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def after_retry=(callable)
|
|
106
|
+
@after_retry = normalize_callable(callable, field: 'after_retry')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def retry_summary=(summary)
|
|
110
|
+
unless summary.respond_to?(:record) && summary.respond_to?(:reset!)
|
|
111
|
+
raise ArgumentError, 'retry_summary must respond to #record and #reset!'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@retry_summary = summary
|
|
115
|
+
end
|
|
116
|
+
|
|
54
117
|
def verbose=(value)
|
|
55
118
|
@verbose = normalize_boolean(value, field: 'verbose')
|
|
56
119
|
end
|
|
@@ -59,13 +122,45 @@ module RSpec
|
|
|
59
122
|
@display_retry_failure_messages = normalize_boolean(value, field: 'display_retry_failure_messages')
|
|
60
123
|
end
|
|
61
124
|
|
|
125
|
+
def display_retry_backtrace_top=(value)
|
|
126
|
+
@display_retry_backtrace_top = normalize_boolean(value, field: 'display_retry_backtrace_top')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def display_retry_summary=(value)
|
|
130
|
+
@display_retry_summary = normalize_boolean(value, field: 'display_retry_summary')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def fail_on_flaky=(value)
|
|
134
|
+
@fail_on_flaky = normalize_boolean(value, field: 'fail_on_flaky')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def max_flaky_examples=(value)
|
|
138
|
+
@max_flaky_examples = value.nil? ? nil : parse_non_negative_integer(value, source: 'max_flaky_examples')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def freeze_configuration_at_suite_start=(value)
|
|
142
|
+
@freeze_configuration_at_suite_start = normalize_boolean(value, field: 'freeze_configuration_at_suite_start')
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def warn_on_delay_conflict=(value)
|
|
146
|
+
@warn_on_delay_conflict = normalize_boolean(value, field: 'warn_on_delay_conflict')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def detect_retry_gem_conflicts=(value)
|
|
150
|
+
@detect_retry_gem_conflicts = normalize_boolean(value, field: 'detect_retry_gem_conflicts')
|
|
151
|
+
end
|
|
152
|
+
|
|
62
153
|
def clear_lets_on_failure=(value)
|
|
63
154
|
@clear_lets_on_failure = normalize_boolean(value, field: 'clear_lets_on_failure')
|
|
64
155
|
end
|
|
65
156
|
|
|
157
|
+
def reset_failure_policy=(mode)
|
|
158
|
+
@reset_failure_policy = normalize_symbol(mode, allowed: %i[raise continue], field: 'reset_failure_policy')
|
|
159
|
+
end
|
|
160
|
+
|
|
66
161
|
def retry_budget=(limit_or_budget)
|
|
67
162
|
@retry_budget =
|
|
68
|
-
if
|
|
163
|
+
if custom_budget?(limit_or_budget)
|
|
69
164
|
limit_or_budget
|
|
70
165
|
else
|
|
71
166
|
RetryBudget.new(limit_or_budget)
|
|
@@ -73,52 +168,103 @@ module RSpec
|
|
|
73
168
|
end
|
|
74
169
|
|
|
75
170
|
def flaky_reporter=(reporter)
|
|
76
|
-
|
|
171
|
+
normalized = reporter || FlakyReporter.null
|
|
172
|
+
raise ArgumentError, 'flaky_reporter must respond to #record' unless normalized.respond_to?(:record)
|
|
173
|
+
|
|
174
|
+
@flaky_reporter = normalized
|
|
175
|
+
@flaky_report_path = reporter_path(normalized)
|
|
77
176
|
end
|
|
78
177
|
|
|
79
178
|
def flaky_report_path=(path)
|
|
80
|
-
@
|
|
179
|
+
@flaky_report_path = path&.to_s
|
|
180
|
+
@flaky_reporter = @flaky_report_path.nil? ? FlakyReporter.null : FlakyReporter.jsonl(@flaky_report_path)
|
|
81
181
|
end
|
|
82
182
|
|
|
83
|
-
|
|
183
|
+
def retry_if_mode=(mode)
|
|
184
|
+
@retry_if_mode = normalize_symbol(mode, allowed: %i[override and or], field: 'retry_if_mode')
|
|
185
|
+
end
|
|
84
186
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
rescue TypeError, ArgumentError
|
|
89
|
-
raise ArgumentError, "#{source} must be a non-negative integer"
|
|
90
|
-
end
|
|
187
|
+
def retry_on_default=(mode)
|
|
188
|
+
@retry_on_default = normalize_symbol(mode, allowed: %i[all standard_errors none], field: 'retry_on_default')
|
|
189
|
+
end
|
|
91
190
|
|
|
92
|
-
|
|
191
|
+
def report_retry_events=(value)
|
|
192
|
+
@report_retry_events = normalize_boolean(value, field: 'report_retry_events')
|
|
193
|
+
end
|
|
93
194
|
|
|
94
|
-
|
|
195
|
+
def strict_callbacks=(value)
|
|
196
|
+
@strict_callbacks = normalize_boolean(value, field: 'strict_callbacks')
|
|
95
197
|
end
|
|
96
198
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
raise ArgumentError, 'backoff must be >= 0' if number.negative?
|
|
199
|
+
def strict_callable_arity=(value)
|
|
200
|
+
@strict_callable_arity = normalize_boolean(value, field: 'strict_callable_arity')
|
|
201
|
+
end
|
|
101
202
|
|
|
102
|
-
|
|
103
|
-
|
|
203
|
+
def strict_matcher_validation=(value)
|
|
204
|
+
normalized = normalize_boolean(value, field: 'strict_matcher_validation')
|
|
205
|
+
validate_existing_matchers_for_strict_mode if normalized
|
|
206
|
+
@strict_matcher_validation = normalized
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def metadata_report_keys=(values)
|
|
210
|
+
@metadata_report_keys = Array(values).flatten.compact.map(&:to_sym)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def max_retries=(value)
|
|
214
|
+
@max_retries = value.nil? ? nil : parse_non_negative_integer(value, source: 'max_retries')
|
|
215
|
+
end
|
|
104
216
|
|
|
105
|
-
|
|
217
|
+
def max_elapsed_time=(value)
|
|
218
|
+
@max_elapsed_time = value.nil? ? nil : normalize_non_negative_float(value, field: 'max_elapsed_time')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def max_total_sleep=(value)
|
|
222
|
+
@max_total_sleep = value.nil? ? nil : normalize_non_negative_float(value, field: 'max_total_sleep')
|
|
223
|
+
end
|
|
106
224
|
|
|
107
|
-
|
|
225
|
+
def sleeper=(callable)
|
|
226
|
+
@sleeper = normalize_callable(callable, field: 'sleeper')
|
|
108
227
|
end
|
|
109
228
|
|
|
110
|
-
def
|
|
111
|
-
|
|
229
|
+
def clock=(callable)
|
|
230
|
+
@clock = normalize_callable(callable, field: 'clock')
|
|
231
|
+
end
|
|
112
232
|
|
|
113
|
-
|
|
233
|
+
def dry_run=(value)
|
|
234
|
+
@dry_run = normalize_boolean(value, field: 'dry_run')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def freeze
|
|
238
|
+
freeze_mutable_policy_state
|
|
239
|
+
super
|
|
240
|
+
end
|
|
114
241
|
|
|
115
|
-
|
|
242
|
+
def snapshot
|
|
243
|
+
copy = dup
|
|
244
|
+
copy.instance_variable_set(:@retry_on, @retry_on.dup)
|
|
245
|
+
copy.instance_variable_set(:@skip_retry_on, @skip_retry_on.dup)
|
|
246
|
+
copy.instance_variable_set(:@metadata_report_keys, @metadata_report_keys.dup)
|
|
247
|
+
copy.freeze
|
|
116
248
|
end
|
|
117
249
|
|
|
118
|
-
|
|
119
|
-
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def freeze_mutable_policy_state
|
|
253
|
+
@retry_on.freeze
|
|
254
|
+
@skip_retry_on.freeze
|
|
255
|
+
@metadata_report_keys.freeze
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def reporter_path(reporter)
|
|
259
|
+
return nil unless reporter.respond_to?(:path)
|
|
260
|
+
|
|
261
|
+
path = reporter.path
|
|
262
|
+
path&.to_s
|
|
263
|
+
end
|
|
120
264
|
|
|
121
|
-
|
|
265
|
+
def validate_existing_matchers_for_strict_mode
|
|
266
|
+
normalize_matchers(@retry_on || [], field: 'retry_on', strict_exception_matchers: true)
|
|
267
|
+
normalize_matchers(@skip_retry_on || [], field: 'skip_retry_on', strict_exception_matchers: true)
|
|
122
268
|
end
|
|
123
269
|
end
|
|
124
270
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
module ConfigurationValidation
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def parse_non_negative_integer(value, source:)
|
|
9
|
+
parsed = begin
|
|
10
|
+
Integer(value)
|
|
11
|
+
rescue TypeError, ArgumentError
|
|
12
|
+
raise ArgumentError, "#{source} must be a non-negative integer"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
raise ArgumentError, "#{source} must be >= 0" if parsed.negative?
|
|
16
|
+
|
|
17
|
+
parsed
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def normalize_backoff(value)
|
|
21
|
+
if value.is_a?(Numeric)
|
|
22
|
+
number = Float(value)
|
|
23
|
+
raise ArgumentError, 'backoff must be >= 0' if number.negative?
|
|
24
|
+
|
|
25
|
+
return number
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return value if value.respond_to?(:call)
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, 'backoff must be a non-negative numeric value or callable'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize_non_negative_float(value, field:)
|
|
34
|
+
parsed = begin
|
|
35
|
+
Float(value)
|
|
36
|
+
rescue TypeError, ArgumentError
|
|
37
|
+
raise ArgumentError, "#{field} must be a numeric value"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise ArgumentError, "#{field} must be >= 0" if parsed.negative?
|
|
41
|
+
|
|
42
|
+
parsed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_callable(callable, field:)
|
|
46
|
+
return nil if callable.nil?
|
|
47
|
+
return callable if callable.respond_to?(:call)
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "#{field} must be nil or callable"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def normalize_boolean(value, field:)
|
|
53
|
+
return value if [true, false].include?(value)
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "#{field} must be true or false"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_symbol(value, allowed:, field:)
|
|
59
|
+
symbol = value.to_sym
|
|
60
|
+
return symbol if allowed.include?(symbol)
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "#{field} must be one of #{allowed.join(', ')}"
|
|
63
|
+
rescue NoMethodError
|
|
64
|
+
raise ArgumentError, "#{field} must be one of #{allowed.join(', ')}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def custom_budget?(value)
|
|
68
|
+
return true if value.is_a?(RetryBudget)
|
|
69
|
+
return false if value.nil? || value.is_a?(Numeric) || value.is_a?(String)
|
|
70
|
+
|
|
71
|
+
value.respond_to?(:consume!) && value.respond_to?(:remaining)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
require_relative 'backoff'
|
|
8
|
+
require_relative 'retry_budget'
|
|
9
|
+
require_relative 'retry_summary'
|
|
10
|
+
require_relative 'flaky_reporter'
|
|
11
|
+
require_relative 'matcher_validation'
|
|
12
|
+
require_relative 'configuration_validation'
|
|
13
|
+
require_relative 'configuration'
|
|
14
|
+
require_relative 'example_context'
|
|
15
|
+
require_relative 'event'
|
|
16
|
+
require_relative 'failure_fingerprint'
|
|
17
|
+
require_relative 'retry_count_resolver'
|
|
18
|
+
require_relative 'retry_delay_resolver'
|
|
19
|
+
require_relative 'retry_event_builder'
|
|
20
|
+
require_relative 'retry_notifier'
|
|
21
|
+
require_relative 'flaky_transition'
|
|
22
|
+
require_relative 'attempt_runner'
|
|
23
|
+
require_relative 'retry_transition'
|
|
24
|
+
require_relative 'retry_gate'
|
|
25
|
+
require_relative 'retry_loop'
|
|
26
|
+
require_relative 'runner_logger'
|
|
27
|
+
require_relative 'runner_components'
|
|
28
|
+
require_relative 'runner_component_factory'
|
|
29
|
+
require_relative 'rspec_adapter'
|
|
30
|
+
require_relative 'example_state_resetter'
|
|
31
|
+
require_relative 'retry_policy'
|
|
32
|
+
require_relative 'retry_decision'
|
|
33
|
+
require_relative 'runner'
|
|
34
|
+
require_relative 'example_methods'
|
|
35
|
+
require_relative 'api'
|
|
36
|
+
|
|
37
|
+
module RSpec
|
|
38
|
+
module Rewind
|
|
39
|
+
class << self
|
|
40
|
+
def configuration
|
|
41
|
+
@configuration ||= Configuration.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def configure
|
|
45
|
+
yield(configuration)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_configuration!
|
|
49
|
+
@configuration = Configuration.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def install!
|
|
53
|
+
return false if installed?
|
|
54
|
+
|
|
55
|
+
warn_on_retry_gem_conflict
|
|
56
|
+
|
|
57
|
+
::RSpec::Core::Example.include(ExampleMethods)
|
|
58
|
+
::RSpec::Core::Example::Procsy.include(ExampleMethods) if defined?(::RSpec::Core::Example::Procsy)
|
|
59
|
+
|
|
60
|
+
::RSpec.configure do |config|
|
|
61
|
+
config.before(:suite) do
|
|
62
|
+
RSpec::Rewind.prepare_suite!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
config.around(:each) do |example|
|
|
66
|
+
if example.metadata[:rewind] == false
|
|
67
|
+
example.run
|
|
68
|
+
else
|
|
69
|
+
example.run_with_rewind
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
config.after(:suite) do
|
|
74
|
+
RSpec::Rewind.close_reporter
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@installed = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def installed?
|
|
82
|
+
!!@installed
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def auto_install_disabled?
|
|
86
|
+
%w[0 false no off].include?(ENV.fetch('RSPEC_REWIND_AUTO_INSTALL', '').downcase)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def close_reporter
|
|
90
|
+
reporter = configuration.flaky_reporter
|
|
91
|
+
lifecycle_error = reporter_lifecycle_error(reporter)
|
|
92
|
+
|
|
93
|
+
publish_retry_summary
|
|
94
|
+
enforce_flaky_threshold!
|
|
95
|
+
raise lifecycle_error if lifecycle_error && configuration.strict_callbacks
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def prepare_suite!
|
|
99
|
+
configuration.retry_summary.reset!
|
|
100
|
+
configuration.freeze if configuration.freeze_configuration_at_suite_start
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def publish_retry_summary
|
|
104
|
+
return unless configuration.display_retry_summary
|
|
105
|
+
|
|
106
|
+
summary = configuration.retry_summary.to_message(budget: configuration.retry_budget)
|
|
107
|
+
::RSpec.configuration.reporter.message(summary)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def enforce_flaky_threshold!
|
|
113
|
+
count = configuration.retry_summary.flaky_examples
|
|
114
|
+
return unless (configuration.fail_on_flaky && count.positive?) || threshold_exceeded?(count)
|
|
115
|
+
|
|
116
|
+
raise FlakyThresholdExceeded, "rspec-rewind observed #{count} flaky example(s)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def threshold_exceeded?(count)
|
|
120
|
+
max = configuration.max_flaky_examples
|
|
121
|
+
!max.nil? && count > max
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def warn_on_retry_gem_conflict
|
|
125
|
+
return unless configuration.detect_retry_gem_conflicts
|
|
126
|
+
return unless Gem.loaded_specs.key?('rspec-retry') || defined?(::RSpec::Retry)
|
|
127
|
+
|
|
128
|
+
warn '[rspec-rewind] rspec-retry appears to be loaded; multiple retry hooks can interfere'
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def reporter_lifecycle_error(reporter)
|
|
134
|
+
%i[flush close].filter_map do |method_name|
|
|
135
|
+
invoke_reporter_lifecycle(reporter, method_name)
|
|
136
|
+
end.first
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def invoke_reporter_lifecycle(reporter, method_name)
|
|
140
|
+
return nil unless reporter.respond_to?(method_name)
|
|
141
|
+
|
|
142
|
+
reporter.public_send(method_name)
|
|
143
|
+
nil
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
warn "[rspec-rewind] flaky reporter #{method_name} failed: #{e.class}: #{e.message}"
|
|
146
|
+
e
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/rspec/rewind/event.rb
CHANGED
|
@@ -4,21 +4,72 @@ module RSpec
|
|
|
4
4
|
module Rewind
|
|
5
5
|
EVENT_SCHEMA_VERSION = 1
|
|
6
6
|
|
|
7
|
-
Event
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
7
|
+
class Event
|
|
8
|
+
FIELDS = %i[
|
|
9
|
+
schema_version
|
|
10
|
+
status
|
|
11
|
+
retry_reason
|
|
12
|
+
decision_reason
|
|
13
|
+
example_id
|
|
14
|
+
description
|
|
15
|
+
location
|
|
16
|
+
attempt
|
|
17
|
+
retries
|
|
18
|
+
max_attempts
|
|
19
|
+
exception_class
|
|
20
|
+
exception_message
|
|
21
|
+
exception_backtrace_top
|
|
22
|
+
failure_fingerprint
|
|
23
|
+
duration
|
|
24
|
+
total_duration
|
|
25
|
+
attempt_durations
|
|
26
|
+
first_failure_duration
|
|
27
|
+
sleep_seconds
|
|
28
|
+
scheduled_sleep_seconds
|
|
29
|
+
actual_sleep_seconds
|
|
30
|
+
sleep_total
|
|
31
|
+
timestamp
|
|
32
|
+
budget_limit
|
|
33
|
+
budget_used
|
|
34
|
+
budget_remaining
|
|
35
|
+
matched_retry_on
|
|
36
|
+
matched_skip_retry_on
|
|
37
|
+
matcher_error
|
|
38
|
+
metadata
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
attr_reader(*FIELDS)
|
|
42
|
+
|
|
43
|
+
def initialize(**attributes)
|
|
44
|
+
FIELDS.each do |field|
|
|
45
|
+
instance_variable_set(:"@#{field}", immutable_value(attributes.fetch(field, nil)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
FIELDS.to_h do |field|
|
|
53
|
+
[field, public_send(field)]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def immutable_value(value)
|
|
60
|
+
case value
|
|
61
|
+
when Array
|
|
62
|
+
value.map { |item| immutable_value(item) }.freeze
|
|
63
|
+
when Hash
|
|
64
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
65
|
+
copy[immutable_value(key)] = immutable_value(item)
|
|
66
|
+
end.freeze
|
|
67
|
+
when String
|
|
68
|
+
value.dup.freeze
|
|
69
|
+
else
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
23
74
|
end
|
|
24
75
|
end
|
|
@@ -3,54 +3,26 @@
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Rewind
|
|
5
5
|
class ExampleStateResetter
|
|
6
|
-
|
|
6
|
+
attr_reader :last_exception
|
|
7
|
+
|
|
8
|
+
def initialize(configuration:, adapter: RSpecAdapter.new)
|
|
7
9
|
@configuration = configuration
|
|
10
|
+
@adapter = adapter
|
|
11
|
+
@last_exception = nil
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def reset(example_source)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
def clear_exception(example_source)
|
|
19
|
-
if example_source.respond_to?(:clear_exception)
|
|
20
|
-
example_source.clear_exception
|
|
21
|
-
elsif example_source.instance_variable_defined?(:@exception)
|
|
22
|
-
example_source.instance_variable_set(:@exception, nil)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def clear_execution_result(example_source)
|
|
27
|
-
return unless example_source.respond_to?(:execution_result)
|
|
15
|
+
@last_exception = nil
|
|
16
|
+
@adapter.clear_exception(example_source)
|
|
17
|
+
@adapter.clear_execution_result(example_source)
|
|
18
|
+
@adapter.clear_lets(example_source) if @configuration.clear_lets_on_failure
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
set_if_writer(result, :exception, nil)
|
|
34
|
-
set_if_writer(result, :pending_message, nil)
|
|
35
|
-
set_if_writer(result, :run_time, nil)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def clear_lets(example_source)
|
|
39
|
-
return unless example_source.respond_to?(:example_group_instance)
|
|
40
|
-
|
|
41
|
-
group_instance = example_source.example_group_instance
|
|
42
|
-
return unless group_instance
|
|
43
|
-
|
|
44
|
-
if group_instance.respond_to?(:clear_lets)
|
|
45
|
-
group_instance.clear_lets
|
|
46
|
-
elsif group_instance.instance_variable_defined?(:@__memoized)
|
|
47
|
-
group_instance.instance_variable_set(:@__memoized, nil)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
20
|
+
true
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
@last_exception = e
|
|
23
|
+
raise if @configuration.reset_failure_policy == :raise
|
|
50
24
|
|
|
51
|
-
|
|
52
|
-
writer = "#{attribute}="
|
|
53
|
-
target.public_send(writer, value) if target.respond_to?(writer)
|
|
25
|
+
false
|
|
54
26
|
end
|
|
55
27
|
end
|
|
56
28
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
module FailureFingerprint
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build(exception)
|
|
9
|
+
return nil unless exception
|
|
10
|
+
|
|
11
|
+
[
|
|
12
|
+
exception.class.name,
|
|
13
|
+
exception.message,
|
|
14
|
+
exception.backtrace&.first
|
|
15
|
+
].join(':')
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|