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
|
@@ -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(
|
|
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(
|
|
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: :
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
data/lib/rspec/rewind/runner.rb
CHANGED
|
@@ -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 ||=
|
|
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(
|
|
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:
|
|
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
|
data/lib/rspec/rewind/version.rb
CHANGED
data/lib/rspec/rewind.rb
CHANGED
|
@@ -1,68 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'time'
|
|
3
|
+
require_relative 'rewind/core'
|
|
5
4
|
|
|
6
|
-
|
|
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?
|