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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +42 -124
  4. data/lib/rspec/rewind/api.rb +37 -0
  5. data/lib/rspec/rewind/attempt_runner.rb +7 -2
  6. data/lib/rspec/rewind/backoff.rb +26 -2
  7. data/lib/rspec/rewind/configuration.rb +176 -30
  8. data/lib/rspec/rewind/configuration_validation.rb +75 -0
  9. data/lib/rspec/rewind/core.rb +150 -0
  10. data/lib/rspec/rewind/event.rb +67 -16
  11. data/lib/rspec/rewind/example_state_resetter.rb +14 -42
  12. data/lib/rspec/rewind/failure_fingerprint.rb +19 -0
  13. data/lib/rspec/rewind/flaky_reporter.rb +18 -16
  14. data/lib/rspec/rewind/flaky_transition.rb +20 -3
  15. data/lib/rspec/rewind/matcher_validation.rb +17 -4
  16. data/lib/rspec/rewind/retry_budget.rb +126 -3
  17. data/lib/rspec/rewind/retry_count_resolver.rb +26 -3
  18. data/lib/rspec/rewind/retry_decision.rb +128 -20
  19. data/lib/rspec/rewind/retry_delay_resolver.rb +57 -6
  20. data/lib/rspec/rewind/retry_event_builder.rb +73 -6
  21. data/lib/rspec/rewind/retry_gate.rb +100 -6
  22. data/lib/rspec/rewind/retry_loop.rb +53 -6
  23. data/lib/rspec/rewind/retry_notifier.rb +55 -8
  24. data/lib/rspec/rewind/retry_policy.rb +137 -9
  25. data/lib/rspec/rewind/retry_summary.rb +61 -0
  26. data/lib/rspec/rewind/retry_transition.rb +189 -8
  27. data/lib/rspec/rewind/rspec_adapter.rb +47 -0
  28. data/lib/rspec/rewind/runner.rb +5 -4
  29. data/lib/rspec/rewind/runner_component_factory.rb +16 -0
  30. data/lib/rspec/rewind/runner_components.rb +11 -5
  31. data/lib/rspec/rewind/version.rb +1 -1
  32. data/lib/rspec/rewind.rb +2 -65
  33. data/sig/rspec/rewind.rbs +290 -24
  34. 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
- :verbose, :display_retry_failure_messages, :clear_lets_on_failure, :retry_budget, :flaky_reporter
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(values, field: 'retry_on')
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(values, field: 'skip_retry_on')
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 limit_or_budget.is_a?(RetryBudget)
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
- @flaky_reporter = reporter || FlakyReporter.null
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
- @flaky_reporter = path.nil? ? FlakyReporter.null : FlakyReporter.jsonl(path)
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
- private
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 parse_non_negative_integer(value, source:)
86
- parsed = begin
87
- Integer(value)
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
- raise ArgumentError, "#{source} must be >= 0" if parsed.negative?
191
+ def report_retry_events=(value)
192
+ @report_retry_events = normalize_boolean(value, field: 'report_retry_events')
193
+ end
93
194
 
94
- parsed
195
+ def strict_callbacks=(value)
196
+ @strict_callbacks = normalize_boolean(value, field: 'strict_callbacks')
95
197
  end
96
198
 
97
- def normalize_backoff(value)
98
- if value.is_a?(Numeric)
99
- number = Float(value)
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
- return number
103
- end
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
- return value if value.respond_to?(:call)
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
- raise ArgumentError, 'backoff must be a non-negative numeric value or callable'
225
+ def sleeper=(callable)
226
+ @sleeper = normalize_callable(callable, field: 'sleeper')
108
227
  end
109
228
 
110
- def normalize_callable(callable, field:)
111
- return nil if callable.nil?
229
+ def clock=(callable)
230
+ @clock = normalize_callable(callable, field: 'clock')
231
+ end
112
232
 
113
- return callable if callable.respond_to?(:call)
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
- raise ArgumentError, "#{field} must be nil or callable"
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
- def normalize_boolean(value, field:)
119
- return value if [true, false].include?(value)
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
- raise ArgumentError, "#{field} must be true or false"
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
@@ -4,21 +4,72 @@ module RSpec
4
4
  module Rewind
5
5
  EVENT_SCHEMA_VERSION = 1
6
6
 
7
- Event = Struct.new(
8
- :schema_version,
9
- :status,
10
- :retry_reason,
11
- :example_id,
12
- :description,
13
- :location,
14
- :attempt,
15
- :retries,
16
- :exception_class,
17
- :exception_message,
18
- :duration,
19
- :sleep_seconds,
20
- :timestamp,
21
- keyword_init: true
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
- def initialize(configuration:)
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
- clear_exception(example_source)
12
- clear_execution_result(example_source)
13
- clear_lets(example_source) if @configuration.clear_lets_on_failure
14
- end
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
- result = example_source.execution_result
30
- return unless result
31
-
32
- set_if_writer(result, :status, nil)
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
- def set_if_writer(target, attribute, value)
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