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
@@ -2,38 +2,219 @@
2
2
 
3
3
  module RSpec
4
4
  module Rewind
5
+ SleepMeasurement = Struct.new(:scheduled, :actual, keyword_init: true)
6
+
5
7
  class RetryTransition
6
- def initialize(configuration:, retry_delay_resolver:, event_builder:, notifier:, state_resetter:, sleep:)
8
+ def initialize(
9
+ configuration:,
10
+ retry_delay_resolver:,
11
+ event_builder:,
12
+ notifier:,
13
+ state_resetter:,
14
+ sleep:,
15
+ clock: nil
16
+ )
7
17
  @configuration = configuration
8
18
  @retry_delay_resolver = retry_delay_resolver
9
19
  @event_builder = event_builder
10
20
  @notifier = notifier
11
21
  @state_resetter = state_resetter
12
22
  @sleep = sleep
23
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
13
24
  end
14
25
 
15
- def perform(retry_number:, resolved_retries:, duration:, exception:, backoff:, wait:, example_source:)
26
+ def perform(
27
+ retry_number:,
28
+ resolved_retries:,
29
+ duration:,
30
+ exception:,
31
+ backoff:,
32
+ wait:,
33
+ example_source:,
34
+ total_duration: nil,
35
+ attempt_durations: nil,
36
+ sleep_total: 0.0,
37
+ failure_fingerprint: nil,
38
+ budget_decision: nil,
39
+ policy_decision: nil
40
+ )
16
41
  sleep_seconds = @retry_delay_resolver.resolve(
17
42
  retry_number: retry_number,
18
43
  backoff: backoff,
19
44
  wait: wait,
20
- exception: exception
45
+ exception: exception,
46
+ resolved_retries: resolved_retries,
47
+ previous_sleep_seconds: sleep_total,
48
+ failure_fingerprint: failure_fingerprint
49
+ )
50
+ sleep_seconds = clamp_sleep_to_budget(sleep_seconds, sleep_total)
51
+
52
+ event_context = {
53
+ retry_number: retry_number,
54
+ resolved_retries: resolved_retries,
55
+ duration: duration,
56
+ sleep_seconds: sleep_seconds,
57
+ total_duration: total_duration,
58
+ attempt_durations: attempt_durations,
59
+ sleep_total: sleep_total,
60
+ budget_decision: budget_decision,
61
+ policy_decision: policy_decision
62
+ }
63
+
64
+ scheduled_event = build_event(
65
+ exception: exception,
66
+ scheduled_sleep_seconds: sleep_seconds,
67
+ actual_sleep_seconds: nil,
68
+ **event_context
69
+ )
70
+ @notifier.notify_before_retry(scheduled_event)
71
+ @notifier.show_failure_message(exception) if @configuration.display_retry_failure_messages
72
+ reset_example_state(
73
+ example_source: example_source,
74
+ retry_exception: exception,
75
+ **event_context
76
+ )
77
+ actual_sleep_seconds = sleep_if_needed(sleep_seconds)
78
+
79
+ event = build_event(
80
+ exception: exception,
81
+ scheduled_sleep_seconds: sleep_seconds,
82
+ actual_sleep_seconds: actual_sleep_seconds,
83
+ **event_context.merge(sleep_total: sleep_total + actual_sleep_seconds)
21
84
  )
22
85
 
86
+ @notifier.notify_retry(event)
87
+ @notifier.notify_after_retry(event)
88
+
89
+ SleepMeasurement.new(scheduled: sleep_seconds, actual: actual_sleep_seconds)
90
+ end
91
+
92
+ def publish_not_retried(
93
+ retry_number:,
94
+ resolved_retries:,
95
+ duration:,
96
+ exception:,
97
+ decision:,
98
+ total_duration: nil,
99
+ attempt_durations: nil,
100
+ sleep_total: 0.0
101
+ )
102
+ policy_decision = decision.policy_decision
23
103
  event = @event_builder.build(
24
- status: :retrying,
104
+ status: :not_retried,
25
105
  retry_reason: :exception,
106
+ decision_reason: decision.reason,
26
107
  attempt: retry_number,
27
108
  retries: resolved_retries,
28
109
  duration: duration,
110
+ total_duration: total_duration,
111
+ attempt_durations: attempt_durations,
112
+ sleep_seconds: 0.0,
113
+ scheduled_sleep_seconds: 0.0,
114
+ actual_sleep_seconds: 0.0,
115
+ sleep_total: sleep_total,
116
+ budget_decision: decision.budget_decision,
117
+ matched_retry_on: policy_decision&.matched_retry_on,
118
+ matched_skip_retry_on: policy_decision&.matched_skip_retry_on,
119
+ matcher_error: policy_decision&.matcher_error,
120
+ exception: exception
121
+ )
122
+
123
+ @notifier.publish_not_retried(event)
124
+ end
125
+
126
+ private
127
+
128
+ def reset_example_state(example_source:, retry_exception:, **event_context)
129
+ reset_result = @state_resetter.reset(example_source)
130
+ return unless reset_result == false
131
+
132
+ publish_reset_failed(
133
+ reset_exception: state_reset_exception || retry_exception,
134
+ **event_context
135
+ )
136
+ rescue StandardError => e
137
+ publish_reset_failed(
138
+ reset_exception: e,
139
+ **event_context
140
+ )
141
+ raise
142
+ end
143
+
144
+ def publish_reset_failed(reset_exception:, **event_context)
145
+ policy_decision = event_context[:policy_decision]
146
+ sleep_seconds = event_context[:sleep_seconds]
147
+ event = @event_builder.build(
148
+ status: :reset_failed,
149
+ retry_reason: :state_reset,
150
+ decision_reason: :state_reset_failed,
151
+ attempt: event_context[:retry_number],
152
+ retries: event_context[:resolved_retries],
153
+ duration: event_context[:duration],
154
+ total_duration: event_context[:total_duration],
155
+ attempt_durations: event_context[:attempt_durations],
29
156
  sleep_seconds: sleep_seconds,
157
+ scheduled_sleep_seconds: sleep_seconds,
158
+ actual_sleep_seconds: 0.0,
159
+ sleep_total: event_context[:sleep_total],
160
+ budget_decision: event_context[:budget_decision],
161
+ matched_retry_on: policy_decision&.matched_retry_on,
162
+ matched_skip_retry_on: policy_decision&.matched_skip_retry_on,
163
+ matcher_error: policy_decision&.matcher_error,
164
+ exception: reset_exception
165
+ )
166
+
167
+ @notifier.publish_reset_failed(event)
168
+ end
169
+
170
+ def state_reset_exception
171
+ return nil unless @state_resetter.respond_to?(:last_exception)
172
+
173
+ @state_resetter.last_exception
174
+ end
175
+
176
+ def build_event(exception:, scheduled_sleep_seconds:, actual_sleep_seconds:, **event_context)
177
+ policy_decision = event_context[:policy_decision]
178
+ @event_builder.build(
179
+ status: :retrying,
180
+ retry_reason: :exception,
181
+ attempt: event_context[:retry_number],
182
+ retries: event_context[:resolved_retries],
183
+ duration: event_context[:duration],
184
+ total_duration: event_context[:total_duration],
185
+ attempt_durations: event_context[:attempt_durations],
186
+ sleep_seconds: event_context[:sleep_seconds],
187
+ scheduled_sleep_seconds: scheduled_sleep_seconds,
188
+ actual_sleep_seconds: actual_sleep_seconds,
189
+ sleep_total: event_context[:sleep_total],
190
+ budget_decision: event_context[:budget_decision],
191
+ matched_retry_on: policy_decision&.matched_retry_on,
192
+ matched_skip_retry_on: policy_decision&.matched_skip_retry_on,
193
+ matcher_error: policy_decision&.matcher_error,
30
194
  exception: exception
31
195
  )
196
+ end
32
197
 
33
- @notifier.notify_retry(event)
34
- @notifier.show_failure_message(exception) if @configuration.display_retry_failure_messages
35
- @state_resetter.reset(example_source)
36
- @sleep.call(sleep_seconds) if sleep_seconds.positive?
198
+ def sleep_if_needed(seconds)
199
+ return 0.0 unless seconds.positive?
200
+
201
+ started_at = monotonic_time
202
+ @sleep.call(seconds)
203
+ monotonic_time - started_at
204
+ end
205
+
206
+ def clamp_sleep_to_budget(seconds, sleep_total)
207
+ max_total_sleep = @configuration.max_total_sleep
208
+ return seconds if max_total_sleep.nil?
209
+
210
+ remaining = max_total_sleep - sleep_total.to_f
211
+ return 0.0 unless remaining.positive?
212
+
213
+ [seconds, remaining].min
214
+ end
215
+
216
+ def monotonic_time
217
+ @clock.call
37
218
  end
38
219
  end
39
220
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class RSpecAdapter
6
+ def clear_exception(example_source)
7
+ if example_source.respond_to?(:clear_exception)
8
+ example_source.clear_exception
9
+ elsif example_source.instance_variable_defined?(:@exception)
10
+ example_source.instance_variable_set(:@exception, nil)
11
+ end
12
+ end
13
+
14
+ def clear_execution_result(example_source)
15
+ return unless example_source.respond_to?(:execution_result)
16
+
17
+ result = example_source.execution_result
18
+ return unless result
19
+
20
+ set_if_writer(result, :status, nil)
21
+ set_if_writer(result, :exception, nil)
22
+ set_if_writer(result, :pending_message, nil)
23
+ set_if_writer(result, :run_time, nil)
24
+ end
25
+
26
+ def clear_lets(example_source)
27
+ return unless example_source.respond_to?(:example_group_instance)
28
+
29
+ group_instance = example_source.example_group_instance
30
+ return unless group_instance
31
+
32
+ if group_instance.respond_to?(:clear_lets)
33
+ group_instance.clear_lets
34
+ elsif group_instance.instance_variable_defined?(:@__memoized)
35
+ group_instance.instance_variable_set(:@__memoized, nil)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def set_if_writer(target, attribute, value)
42
+ writer = "#{attribute}="
43
+ target.public_send(writer, value) if target.respond_to?(writer)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -3,11 +3,12 @@
3
3
  module RSpec
4
4
  module Rewind
5
5
  class Runner
6
- def initialize(example:, configuration:)
6
+ def initialize(example:, configuration:, component_factory: RunnerComponentFactory.new)
7
7
  @example = example
8
- @configuration = configuration
8
+ @configuration = configuration.snapshot
9
+ @component_factory = component_factory
9
10
  @context = ExampleContext.new(example: example)
10
- @logger = RunnerLogger.new(configuration: configuration, warn_output: method(:warn))
11
+ @logger = RunnerLogger.new(configuration: @configuration, warn_output: method(:warn))
11
12
  end
12
13
 
13
14
  def run(retries: nil, backoff: nil, wait: nil, retry_on: nil, skip_retry_on: nil, retry_if: nil)
@@ -24,7 +25,7 @@ module RSpec
24
25
  private
25
26
 
26
27
  def components
27
- @components ||= RunnerComponents.new(
28
+ @components ||= @component_factory.build(
28
29
  example: @example,
29
30
  configuration: @configuration,
30
31
  context: @context,
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class RunnerComponentFactory
6
+ def build(example:, configuration:, context:, logger:)
7
+ RunnerComponents.new(
8
+ example: example,
9
+ configuration: configuration,
10
+ context: context,
11
+ logger: logger
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
@@ -11,7 +11,10 @@ module RSpec
11
11
  :retry_loop
12
12
 
13
13
  def initialize(example:, configuration:, context:, logger:)
14
- event_builder = RetryEventBuilder.new(example_source: context.source)
14
+ event_builder = RetryEventBuilder.new(
15
+ example_source: context.source,
16
+ metadata_keys: configuration.metadata_report_keys
17
+ )
15
18
  notifier = RetryNotifier.new(
16
19
  configuration: configuration,
17
20
  debug: logger.method(:debug),
@@ -26,14 +29,15 @@ module RSpec
26
29
  retry_delay_resolver = RetryDelayResolver.new(
27
30
  configuration: configuration,
28
31
  metadata: context.metadata,
29
- example: example
32
+ example: example,
33
+ warn: logger.method(:reporter_message)
30
34
  )
31
35
 
32
36
  @retry_count_resolver = RetryCountResolver.new(
33
37
  configuration: configuration,
34
38
  metadata: context.metadata
35
39
  )
36
- @attempt_runner = AttemptRunner.new
40
+ @attempt_runner = AttemptRunner.new(clock: configuration.clock)
37
41
  @retry_gate = RetryGate.new(
38
42
  configuration: configuration,
39
43
  retry_policy: retry_policy,
@@ -45,7 +49,8 @@ module RSpec
45
49
  event_builder: event_builder,
46
50
  notifier: notifier,
47
51
  state_resetter: state_resetter,
48
- sleep: Kernel.method(:sleep)
52
+ sleep: configuration.sleeper,
53
+ clock: configuration.clock
49
54
  )
50
55
  @flaky_transition = FlakyTransition.new(
51
56
  event_builder: event_builder,
@@ -58,7 +63,8 @@ module RSpec
58
63
  attempt_runner: @attempt_runner,
59
64
  retry_gate: @retry_gate,
60
65
  retry_transition: @retry_transition,
61
- flaky_transition: @flaky_transition
66
+ flaky_transition: @flaky_transition,
67
+ clock: configuration.clock
62
68
  )
63
69
  end
64
70
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Rewind
5
- VERSION = '0.1.0'
5
+ VERSION = '1.0.0'
6
6
  end
7
7
  end
data/lib/rspec/rewind.rb CHANGED
@@ -1,68 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rspec/core'
4
- require 'time'
3
+ require_relative 'rewind/core'
5
4
 
6
- require_relative 'rewind/version'
7
- require_relative 'rewind/backoff'
8
- require_relative 'rewind/retry_budget'
9
- require_relative 'rewind/flaky_reporter'
10
- require_relative 'rewind/matcher_validation'
11
- require_relative 'rewind/configuration'
12
- require_relative 'rewind/example_context'
13
- require_relative 'rewind/event'
14
- require_relative 'rewind/retry_count_resolver'
15
- require_relative 'rewind/retry_delay_resolver'
16
- require_relative 'rewind/retry_event_builder'
17
- require_relative 'rewind/retry_notifier'
18
- require_relative 'rewind/flaky_transition'
19
- require_relative 'rewind/attempt_runner'
20
- require_relative 'rewind/retry_transition'
21
- require_relative 'rewind/retry_gate'
22
- require_relative 'rewind/retry_loop'
23
- require_relative 'rewind/runner_logger'
24
- require_relative 'rewind/runner_components'
25
- require_relative 'rewind/example_state_resetter'
26
- require_relative 'rewind/retry_policy'
27
- require_relative 'rewind/retry_decision'
28
- require_relative 'rewind/runner'
29
- require_relative 'rewind/example_methods'
30
-
31
- module RSpec
32
- module Rewind
33
- class << self
34
- def configuration
35
- @configuration ||= Configuration.new
36
- end
37
-
38
- def configure
39
- yield(configuration)
40
- end
41
-
42
- def reset_configuration!
43
- @configuration = Configuration.new
44
- end
45
-
46
- def install!
47
- return if @installed
48
-
49
- ::RSpec::Core::Example.include(ExampleMethods)
50
- ::RSpec::Core::Example::Procsy.include(ExampleMethods) if defined?(::RSpec::Core::Example::Procsy)
51
-
52
- ::RSpec.configure do |config|
53
- config.around(:each) do |example|
54
- if example.metadata[:rewind] == false
55
- example.run
56
- else
57
- example.run_with_rewind
58
- end
59
- end
60
- end
61
-
62
- @installed = true
63
- end
64
- end
65
- end
66
- end
67
-
68
- RSpec::Rewind.install!
5
+ RSpec::Rewind.install! unless RSpec::Rewind.auto_install_disabled?