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
@@ -3,27 +3,94 @@
3
3
  module RSpec
4
4
  module Rewind
5
5
  class RetryEventBuilder
6
- def initialize(example_source:)
6
+ def initialize(example_source:, metadata_keys: [])
7
7
  @example_source = example_source
8
+ @metadata_keys = Array(metadata_keys).map(&:to_sym)
8
9
  end
9
10
 
10
- def build(status:, retry_reason:, attempt:, retries:, duration:, sleep_seconds:, exception:)
11
+ def build(
12
+ status:,
13
+ retry_reason:,
14
+ attempt:,
15
+ retries:,
16
+ duration:,
17
+ sleep_seconds:,
18
+ exception:,
19
+ decision_reason: nil,
20
+ total_duration: nil,
21
+ attempt_durations: nil,
22
+ first_failure_duration: nil,
23
+ scheduled_sleep_seconds: sleep_seconds,
24
+ actual_sleep_seconds: nil,
25
+ sleep_total: nil,
26
+ budget_decision: nil,
27
+ matched_retry_on: nil,
28
+ matched_skip_retry_on: nil,
29
+ matcher_error: nil
30
+ )
11
31
  Event.new(
12
32
  schema_version: EVENT_SCHEMA_VERSION,
13
33
  status: status,
14
34
  retry_reason: retry_reason,
15
- example_id: @example_source.id,
16
- description: @example_source.full_description,
17
- location: @example_source.location,
35
+ decision_reason: decision_reason,
36
+ example_id: safe_read(:id, 'unknown'),
37
+ description: safe_read(:full_description, 'unknown'),
38
+ location: safe_read(:location, 'unknown'),
18
39
  attempt: attempt,
19
40
  retries: retries,
41
+ max_attempts: retries.to_i + 1,
20
42
  exception_class: exception&.class&.name,
21
43
  exception_message: exception&.message,
44
+ exception_backtrace_top: exception&.backtrace&.first,
45
+ failure_fingerprint: FailureFingerprint.build(exception),
22
46
  duration: duration,
47
+ total_duration: total_duration,
48
+ attempt_durations: attempt_durations,
49
+ first_failure_duration: first_failure_duration,
23
50
  sleep_seconds: sleep_seconds,
24
- timestamp: Time.now.utc.iso8601
51
+ scheduled_sleep_seconds: scheduled_sleep_seconds,
52
+ actual_sleep_seconds: actual_sleep_seconds,
53
+ sleep_total: sleep_total,
54
+ timestamp: Time.now.utc.iso8601,
55
+ budget_limit: read_decision_value(budget_decision, :limit),
56
+ budget_used: read_decision_value(budget_decision, :used),
57
+ budget_remaining: read_decision_value(budget_decision, :remaining),
58
+ matched_retry_on: matched_retry_on,
59
+ matched_skip_retry_on: matched_skip_retry_on,
60
+ matcher_error: matcher_error,
61
+ metadata: filtered_metadata
25
62
  )
26
63
  end
64
+
65
+ private
66
+
67
+ def safe_read(method_name, fallback)
68
+ return fallback unless @example_source.respond_to?(method_name)
69
+
70
+ value = @example_source.public_send(method_name)
71
+ value.nil? ? fallback : value
72
+ rescue StandardError
73
+ fallback
74
+ end
75
+
76
+ def filtered_metadata
77
+ return nil if @metadata_keys.empty?
78
+ return {} unless @example_source.respond_to?(:metadata)
79
+
80
+ metadata = @example_source.metadata || {}
81
+ @metadata_keys.each_with_object({}) do |key, selected|
82
+ selected[key] = metadata[key] if metadata.key?(key)
83
+ end
84
+ rescue StandardError
85
+ {}
86
+ end
87
+
88
+ def read_decision_value(decision, field)
89
+ return nil unless decision
90
+ return decision.public_send(field) if decision.respond_to?(field)
91
+
92
+ nil
93
+ end
27
94
  end
28
95
  end
29
96
  end
@@ -2,6 +2,18 @@
2
2
 
3
3
  module RSpec
4
4
  module Rewind
5
+ RetryGateDecision = Struct.new(
6
+ :allowed,
7
+ :reason,
8
+ :policy_decision,
9
+ :budget_decision,
10
+ keyword_init: true
11
+ ) do
12
+ def allowed?
13
+ allowed
14
+ end
15
+ end
16
+
5
17
  class RetryGate
6
18
  def initialize(configuration:, retry_policy:, debug:)
7
19
  @configuration = configuration
@@ -16,25 +28,107 @@ module RSpec
16
28
  retry_on:,
17
29
  skip_retry_on:,
18
30
  retry_if:,
19
- example_id:
31
+ example_id:,
32
+ elapsed_time: nil,
33
+ sleep_total: nil
20
34
  )
21
- return false unless retry_number <= resolved_retries
35
+ decision(
36
+ exception: exception,
37
+ retry_number: retry_number,
38
+ resolved_retries: resolved_retries,
39
+ retry_on: retry_on,
40
+ skip_retry_on: skip_retry_on,
41
+ retry_if: retry_if,
42
+ example_id: example_id,
43
+ elapsed_time: elapsed_time,
44
+ sleep_total: sleep_total
45
+ ).allowed?
46
+ end
22
47
 
23
- return false unless @retry_policy.retry_allowed?(
48
+ def decision(
49
+ exception:,
50
+ retry_number:,
51
+ resolved_retries:,
52
+ retry_on:,
53
+ skip_retry_on:,
54
+ retry_if:,
55
+ example_id:,
56
+ elapsed_time: nil,
57
+ sleep_total: nil
58
+ )
59
+ return rejected(:retries_exhausted) unless retry_number <= resolved_retries
60
+ return rejected(:max_elapsed_time_exceeded) if exceeded?(@configuration.max_elapsed_time, elapsed_time)
61
+ return rejected(:max_total_sleep_exceeded) if exceeded?(@configuration.max_total_sleep, sleep_total)
62
+
63
+ policy_decision = @retry_policy.decision(
24
64
  exception: exception,
25
65
  retry_on: retry_on,
26
66
  skip_retry_on: skip_retry_on,
27
- retry_if: retry_if
67
+ retry_if: retry_if,
68
+ retry_number: retry_number,
69
+ resolved_retries: resolved_retries,
70
+ budget_remaining: budget_remaining,
71
+ elapsed_time: elapsed_time,
72
+ sleep_total: sleep_total
28
73
  )
74
+ return rejected(policy_decision.reason, policy_decision: policy_decision) unless policy_decision.allowed?
29
75
 
30
- return true if @configuration.retry_budget.consume!
76
+ return rejected(:dry_run, policy_decision: policy_decision) if @configuration.dry_run
77
+
78
+ budget_decision = consume_budget
79
+ return allowed(policy_decision: policy_decision, budget_decision: budget_decision) if budget_decision.allowed?
31
80
 
32
81
  debug("retry budget exhausted for #{example_id}")
33
- false
82
+ rejected(:budget_exhausted, policy_decision: policy_decision, budget_decision: budget_decision)
34
83
  end
35
84
 
36
85
  private
37
86
 
87
+ def allowed(policy_decision: nil, budget_decision: nil)
88
+ RetryGateDecision.new(
89
+ allowed: true,
90
+ reason: :allowed,
91
+ policy_decision: policy_decision,
92
+ budget_decision: budget_decision
93
+ )
94
+ end
95
+
96
+ def rejected(reason, policy_decision: nil, budget_decision: nil)
97
+ RetryGateDecision.new(
98
+ allowed: false,
99
+ reason: reason,
100
+ policy_decision: policy_decision,
101
+ budget_decision: budget_decision
102
+ )
103
+ end
104
+
105
+ def consume_budget
106
+ budget = @configuration.retry_budget
107
+ return budget.consume if budget.respond_to?(:consume)
108
+
109
+ allowed = budget.consume!
110
+ BudgetDecision.new(
111
+ allowed: allowed,
112
+ limit: read_budget_value(budget, :limit),
113
+ used: read_budget_value(budget, :used),
114
+ remaining: read_budget_value(budget, :remaining)
115
+ )
116
+ end
117
+
118
+ def budget_remaining
119
+ read_budget_value(@configuration.retry_budget, :remaining)
120
+ end
121
+
122
+ def read_budget_value(budget, field)
123
+ return budget.public_send(field) if budget.respond_to?(field)
124
+
125
+ nil
126
+ end
127
+
128
+ def exceeded?(limit, value)
129
+ !limit.nil? && !value.nil? && value >= limit
130
+ end
131
+
38
132
  def debug(message)
39
133
  @debug.call(message)
40
134
  end
@@ -10,7 +10,8 @@ module RSpec
10
10
  attempt_runner:,
11
11
  retry_gate:,
12
12
  retry_transition:,
13
- flaky_transition:
13
+ flaky_transition:,
14
+ clock: nil
14
15
  )
15
16
  @example = example
16
17
  @context = context
@@ -19,6 +20,7 @@ module RSpec
19
20
  @retry_gate = retry_gate
20
21
  @retry_transition = retry_transition
21
22
  @flaky_transition = flaky_transition
23
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
22
24
  end
23
25
 
24
26
  def run(retries:, backoff:, wait:, retry_on:, skip_retry_on:, retry_if:)
@@ -27,46 +29,91 @@ module RSpec
27
29
 
28
30
  total_attempts = resolved_retries + 1
29
31
  attempt = 1
32
+ first_exception = nil
33
+ first_failure_duration = nil
34
+ attempt_durations = []
35
+ sleep_total = 0.0
36
+ started_at = monotonic_time
30
37
 
31
38
  while attempt <= total_attempts
32
39
  exception, duration, raised = @attempt_runner.run(
33
40
  run_target: @example,
34
41
  exception_source: @context.source
35
42
  )
43
+ attempt_durations << duration
36
44
 
37
45
  if exception.nil?
38
- @flaky_transition.perform(attempt: attempt, retries: resolved_retries, duration: duration) if attempt > 1
46
+ if attempt > 1
47
+ @flaky_transition.perform(
48
+ attempt: attempt,
49
+ retries: resolved_retries,
50
+ duration: duration,
51
+ exception: first_exception,
52
+ total_duration: monotonic_time - started_at,
53
+ attempt_durations: attempt_durations.dup,
54
+ first_failure_duration: first_failure_duration,
55
+ sleep_total: sleep_total
56
+ )
57
+ end
39
58
  return
40
59
  end
41
60
 
61
+ first_exception ||= exception
62
+ first_failure_duration ||= duration
42
63
  retry_number = attempt
43
- unless @retry_gate.allow?(
64
+ decision = @retry_gate.decision(
44
65
  exception: exception,
45
66
  retry_number: retry_number,
46
67
  resolved_retries: resolved_retries,
47
68
  retry_on: retry_on,
48
69
  skip_retry_on: skip_retry_on,
49
70
  retry_if: retry_if,
50
- example_id: @context.id
71
+ example_id: @context.id,
72
+ elapsed_time: monotonic_time - started_at,
73
+ sleep_total: sleep_total
51
74
  )
75
+ unless decision.allowed?
76
+ @retry_transition.publish_not_retried(
77
+ retry_number: retry_number,
78
+ resolved_retries: resolved_retries,
79
+ duration: duration,
80
+ exception: exception,
81
+ decision: decision,
82
+ total_duration: monotonic_time - started_at,
83
+ attempt_durations: attempt_durations.dup,
84
+ sleep_total: sleep_total
85
+ )
52
86
  raise exception if raised
53
87
 
54
88
  return
55
89
  end
56
90
 
57
- @retry_transition.perform(
91
+ sleep_measurement = @retry_transition.perform(
58
92
  retry_number: retry_number,
59
93
  resolved_retries: resolved_retries,
60
94
  duration: duration,
61
95
  exception: exception,
62
96
  backoff: backoff,
63
97
  wait: wait,
64
- example_source: @context.source
98
+ example_source: @context.source,
99
+ total_duration: monotonic_time - started_at,
100
+ attempt_durations: attempt_durations.dup,
101
+ sleep_total: sleep_total,
102
+ failure_fingerprint: FailureFingerprint.build(exception),
103
+ budget_decision: decision.budget_decision,
104
+ policy_decision: decision.policy_decision
65
105
  )
106
+ sleep_total += sleep_measurement.actual
66
107
 
67
108
  attempt += 1
68
109
  end
69
110
  end
111
+
112
+ private
113
+
114
+ def monotonic_time
115
+ @clock.call
116
+ end
70
117
  end
71
118
  end
72
119
  end
@@ -11,32 +11,79 @@ module RSpec
11
11
 
12
12
  def notify_retry(event)
13
13
  debug("retry #{event.attempt}/#{event.retries} for #{event.example_id} in #{event.sleep_seconds.round(3)}s")
14
- @configuration.retry_callback&.call(event)
15
- rescue StandardError => e
16
- debug("retry callback failed: #{e.class}: #{e.message}")
14
+ record_summary(event)
15
+ publish_retry_report(event) if @configuration.report_retry_events
16
+ invoke_callback(@configuration.retry_callback, event, 'retry callback')
17
+ end
18
+
19
+ def notify_before_retry(event)
20
+ invoke_callback(@configuration.before_retry, event, 'before_retry hook')
21
+ end
22
+
23
+ def notify_after_retry(event)
24
+ invoke_callback(@configuration.after_retry, event, 'after_retry hook')
25
+ end
26
+
27
+ def publish_not_retried(event)
28
+ debug("not retrying #{event.example_id}: #{event.decision_reason}")
29
+ record_summary(event)
30
+ publish_retry_report(event) if @configuration.report_retry_events
31
+ invoke_callback(@configuration.not_retried_callback, event, 'not_retried callback')
32
+ end
33
+
34
+ def publish_reset_failed(event)
35
+ debug("state reset failed for #{event.example_id}: #{event.exception_class}: #{event.exception_message}")
36
+ record_summary(event)
37
+ publish_retry_report(event) if @configuration.report_retry_events
17
38
  end
18
39
 
19
40
  def publish_flaky(event)
41
+ record_summary(event)
20
42
  publish_flaky_report(event)
21
- invoke_flaky_callback(event)
43
+ invoke_callback(@configuration.flaky_callback, event, 'flaky callback')
22
44
  end
23
45
 
24
46
  def show_failure_message(exception)
25
- @reporter_message.call("[rspec-rewind] #{exception.class}: #{exception.message}")
47
+ message = "[rspec-rewind] #{exception.class}: #{exception.message}"
48
+ if @configuration.display_retry_backtrace_top && exception.backtrace&.first
49
+ message = "#{message} (#{exception.backtrace.first})"
50
+ end
51
+
52
+ @reporter_message.call(message)
26
53
  end
27
54
 
28
55
  private
29
56
 
57
+ def publish_retry_report(event)
58
+ @configuration.flaky_reporter.record(event)
59
+ rescue StandardError => e
60
+ raise if @configuration.strict_callbacks
61
+
62
+ debug("failed to record retry event: #{e.class}: #{e.message}")
63
+ end
64
+
65
+ def record_summary(event)
66
+ @configuration.retry_summary.record(event)
67
+ rescue StandardError => e
68
+ raise if @configuration.strict_callbacks
69
+
70
+ debug("failed to record retry summary: #{e.class}: #{e.message}")
71
+ end
72
+
30
73
  def publish_flaky_report(event)
31
74
  @configuration.flaky_reporter.record(event)
32
75
  rescue StandardError => e
76
+ raise if @configuration.strict_callbacks
77
+
33
78
  debug("failed to record flaky event: #{e.class}: #{e.message}")
34
79
  end
35
80
 
36
- def invoke_flaky_callback(event)
37
- @configuration.flaky_callback&.call(event)
81
+ def invoke_callback(callback, event, label)
82
+ callback&.call(event)
38
83
  rescue StandardError => e
39
- debug("flaky callback failed: #{e.class}: #{e.message}")
84
+ raise if @configuration.strict_callbacks
85
+
86
+ debug("#{label} failed: #{e.class}: #{e.message}")
40
87
  end
41
88
 
42
89
  def debug(message)
@@ -12,40 +12,168 @@ module RSpec
12
12
  end
13
13
 
14
14
  def retry_allowed?(exception:, retry_on:, skip_retry_on:, retry_if:)
15
+ decision(
16
+ exception: exception,
17
+ retry_on: retry_on,
18
+ skip_retry_on: skip_retry_on,
19
+ retry_if: retry_if
20
+ ).allowed?
21
+ end
22
+
23
+ def decision(
24
+ exception:,
25
+ retry_on:,
26
+ skip_retry_on:,
27
+ retry_if:,
28
+ retry_number: nil,
29
+ resolved_retries: nil,
30
+ budget_remaining: nil,
31
+ elapsed_time: nil,
32
+ sleep_total: nil
33
+ )
15
34
  RetryDecision.new(
16
35
  exception: exception,
17
36
  example: @example,
18
37
  retry_on: effective_retry_on(retry_on),
19
38
  skip_retry_on: effective_skip_retry_on(skip_retry_on),
20
- retry_if: effective_retry_if(retry_if)
21
- ).retry?
39
+ retry_if: effective_retry_if(retry_if),
40
+ retry_on_default: @configuration.retry_on_default,
41
+ strict_callable_arity: @configuration.strict_callable_arity,
42
+ context: retry_context(
43
+ retry_number: retry_number,
44
+ resolved_retries: resolved_retries,
45
+ budget_remaining: budget_remaining,
46
+ elapsed_time: elapsed_time,
47
+ sleep_total: sleep_total,
48
+ exception: exception
49
+ )
50
+ ).decision
22
51
  end
23
52
 
24
53
  private
25
54
 
26
55
  def effective_retry_on(explicit_retry_on)
27
- normalize_matchers(@configuration.retry_on, field: 'retry_on') +
28
- normalize_matchers(@metadata[:rewind_retry_on], field: 'rewind_retry_on') +
29
- normalize_matchers(explicit_retry_on, field: 'retry_on')
56
+ if retry_on_mode == :override
57
+ return normalize_policy_matchers(@metadata[:rewind_retry_on], field: 'rewind_retry_on') +
58
+ normalize_policy_matchers(explicit_retry_on, field: 'retry_on')
59
+ end
60
+
61
+ normalize_policy_matchers(@configuration.retry_on, field: 'retry_on') +
62
+ normalize_policy_matchers(@metadata[:rewind_retry_on], field: 'rewind_retry_on') +
63
+ normalize_policy_matchers(explicit_retry_on, field: 'retry_on')
30
64
  end
31
65
 
32
66
  def effective_skip_retry_on(explicit_skip_retry_on)
33
- normalize_matchers(@configuration.skip_retry_on, field: 'skip_retry_on') +
34
- normalize_matchers(metadata_skip_retry_on, field: 'rewind_skip_retry_on') +
35
- normalize_matchers(explicit_skip_retry_on, field: 'skip_retry_on')
67
+ if skip_retry_on_mode == :override
68
+ return normalize_policy_matchers(metadata_skip_retry_on, field: 'rewind_skip_retry_on') +
69
+ normalize_policy_matchers(explicit_skip_retry_on, field: 'skip_retry_on')
70
+ end
71
+
72
+ normalize_policy_matchers(@configuration.skip_retry_on, field: 'skip_retry_on') +
73
+ normalize_policy_matchers(metadata_skip_retry_on, field: 'rewind_skip_retry_on') +
74
+ normalize_policy_matchers(explicit_skip_retry_on, field: 'skip_retry_on')
36
75
  end
37
76
 
38
77
  def effective_retry_if(explicit_retry_if)
39
- first_non_nil(explicit_retry_if, @metadata[:rewind_if], @configuration.retry_if)
78
+ predicates = [@configuration.retry_if, @metadata[:rewind_if], explicit_retry_if].compact
79
+ predicates.each { |predicate| validate_callable!(predicate, field: 'retry_if') }
80
+
81
+ case retry_if_mode
82
+ when :and
83
+ return nil if predicates.empty?
84
+
85
+ ->(*args) { predicates.all? { |predicate| call_predicate(predicate, args) } }
86
+ when :or
87
+ return nil if predicates.empty?
88
+
89
+ ->(*args) { predicates.any? { |predicate| call_predicate(predicate, args) } }
90
+ else
91
+ first_non_nil(explicit_retry_if, @metadata[:rewind_if], @configuration.retry_if)
92
+ end
40
93
  end
41
94
 
42
95
  def metadata_skip_retry_on
43
96
  @metadata[:rewind_skip_retry_on]
44
97
  end
45
98
 
99
+ def normalize_policy_matchers(values, field:)
100
+ normalize_matchers(
101
+ values,
102
+ field: field,
103
+ strict_exception_matchers: @configuration.strict_matcher_validation
104
+ )
105
+ end
106
+
107
+ def retry_if_mode
108
+ normalize_mode(
109
+ @metadata[:rewind_if_mode],
110
+ allowed: %i[override and or],
111
+ fallback: @configuration.retry_if_mode,
112
+ field: 'rewind_if_mode'
113
+ )
114
+ end
115
+
116
+ def retry_on_mode
117
+ normalize_mode(
118
+ @metadata[:rewind_retry_on_mode],
119
+ allowed: %i[append override],
120
+ fallback: :append,
121
+ field: 'rewind_retry_on_mode'
122
+ )
123
+ end
124
+
125
+ def skip_retry_on_mode
126
+ normalize_mode(
127
+ @metadata[:rewind_skip_retry_on_mode],
128
+ allowed: %i[append override],
129
+ fallback: :append,
130
+ field: 'rewind_skip_retry_on_mode'
131
+ )
132
+ end
133
+
134
+ def retry_context(retry_number:, resolved_retries:, budget_remaining:, elapsed_time:, sleep_total:, exception:)
135
+ RetryContext.new(
136
+ attempt: retry_number,
137
+ retries: resolved_retries,
138
+ metadata: @metadata,
139
+ budget_remaining: budget_remaining,
140
+ failure_fingerprint: FailureFingerprint.build(exception),
141
+ elapsed_time: elapsed_time,
142
+ sleep_total: sleep_total
143
+ )
144
+ end
145
+
146
+ def validate_callable!(callable, field:)
147
+ return if callable.respond_to?(:call)
148
+
149
+ raise ArgumentError, "#{field} must be callable"
150
+ end
151
+
152
+ def call_predicate(predicate, args)
153
+ arity = predicate.respond_to?(:arity) ? predicate.arity : predicate.method(:call).arity
154
+ return predicate.call if arity.zero?
155
+
156
+ if arity.positive?
157
+ predicate.call(*args.take(arity))
158
+ else
159
+ predicate.call(*args)
160
+ end
161
+ end
162
+
46
163
  def first_non_nil(*values)
47
164
  values.find { |value| !value.nil? }
48
165
  end
166
+
167
+ def normalize_mode(value, allowed:, fallback:, field:)
168
+ return fallback.to_sym if value.nil?
169
+
170
+ mode = value.to_sym
171
+ return mode if allowed.include?(mode)
172
+
173
+ raise ArgumentError, "#{field} must be one of: #{allowed.join(', ')}"
174
+ rescue NoMethodError
175
+ raise ArgumentError, "#{field} must be one of: #{allowed.join(', ')}"
176
+ end
49
177
  end
50
178
  end
51
179
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class FlakyThresholdExceeded < StandardError; end
6
+
7
+ class RetrySummary
8
+ attr_reader :retry_events, :flaky_examples, :not_retried_events, :reset_failed_events, :sleep_seconds
9
+
10
+ def initialize
11
+ reset!
12
+ end
13
+
14
+ def record(event)
15
+ @mutex.synchronize do
16
+ case event.status
17
+ when :retrying
18
+ @retry_events += 1
19
+ @sleep_seconds += event.actual_sleep_seconds.to_f
20
+ when :flaky
21
+ @flaky_examples += 1
22
+ when :not_retried
23
+ @not_retried_events += 1
24
+ when :reset_failed
25
+ @reset_failed_events += 1
26
+ end
27
+ end
28
+ end
29
+
30
+ def reset!
31
+ @mutex = Mutex.new
32
+ @retry_events = 0
33
+ @flaky_examples = 0
34
+ @not_retried_events = 0
35
+ @reset_failed_events = 0
36
+ @sleep_seconds = 0.0
37
+ end
38
+
39
+ def to_message(budget:)
40
+ parts = [
41
+ "#{@flaky_examples} flaky examples",
42
+ "#{@retry_events} retry attempts",
43
+ "#{format('%.3f', @sleep_seconds)}s spent sleeping",
44
+ "#{@not_retried_events} not retried",
45
+ "#{@reset_failed_events} reset failures"
46
+ ]
47
+ parts << budget_message(budget)
48
+ "[rspec-rewind] #{parts.compact.join(', ')}"
49
+ end
50
+
51
+ private
52
+
53
+ def budget_message(budget)
54
+ return nil unless budget.respond_to?(:used)
55
+
56
+ limit = budget.respond_to?(:limit) ? budget.limit : nil
57
+ limit.nil? ? "budget #{budget.used}/unlimited used" : "budget #{budget.used}/#{limit} used"
58
+ end
59
+ end
60
+ end
61
+ end