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
@@ -18,36 +18,38 @@ module RSpec
18
18
 
19
19
  class NullReporter
20
20
  def record(_event); end
21
+
22
+ def flush; end
23
+
24
+ def close; end
21
25
  end
22
26
 
23
27
  class JsonlReporter
28
+ attr_reader :path
29
+
24
30
  def initialize(path)
25
31
  @path = path
26
32
  @mutex = Mutex.new
27
33
  end
28
34
 
29
35
  def record(event)
30
- payload = {
31
- schema_version: event.schema_version,
32
- status: event.status,
33
- retry_reason: event.retry_reason,
34
- example_id: event.example_id,
35
- description: event.description,
36
- location: event.location,
37
- attempt: event.attempt,
38
- retries: event.retries,
39
- exception_class: event.exception_class,
40
- exception_message: event.exception_message,
41
- duration: event.duration,
42
- sleep_seconds: event.sleep_seconds,
43
- timestamp: event.timestamp
44
- }
36
+ payload = event.respond_to?(:to_h) ? event.to_h : {}
45
37
 
46
38
  @mutex.synchronize do
47
39
  FileUtils.mkdir_p(File.dirname(@path))
48
- File.open(@path, 'a') { |file| file.puts(JSON.generate(payload)) }
40
+ File.open(@path, 'a') do |file|
41
+ file.flock(File::LOCK_EX)
42
+ file.puts(JSON.generate(payload))
43
+ file.flush
44
+ ensure
45
+ file&.flock(File::LOCK_UN)
46
+ end
49
47
  end
50
48
  end
49
+
50
+ def flush; end
51
+
52
+ def close; end
51
53
  end
52
54
  end
53
55
  end
@@ -8,15 +8,32 @@ module RSpec
8
8
  @notifier = notifier
9
9
  end
10
10
 
11
- def perform(attempt:, retries:, duration:)
11
+ def perform(
12
+ attempt:,
13
+ retries:,
14
+ duration:,
15
+ exception:,
16
+ total_duration: nil,
17
+ attempt_durations: nil,
18
+ first_failure_duration: nil,
19
+ sleep_total: 0.0,
20
+ budget_decision: nil
21
+ )
12
22
  event = @event_builder.build(
13
23
  status: :flaky,
14
- retry_reason: nil,
24
+ retry_reason: :exception,
15
25
  attempt: attempt,
16
26
  retries: retries,
17
27
  duration: duration,
28
+ total_duration: total_duration,
29
+ attempt_durations: attempt_durations,
30
+ first_failure_duration: first_failure_duration,
18
31
  sleep_seconds: 0.0,
19
- exception: nil
32
+ scheduled_sleep_seconds: 0.0,
33
+ actual_sleep_seconds: 0.0,
34
+ sleep_total: sleep_total,
35
+ budget_decision: budget_decision,
36
+ exception: exception
20
37
  )
21
38
 
22
39
  @notifier.publish_flaky(event)
@@ -5,14 +5,27 @@ module RSpec
5
5
  module MatcherValidation
6
6
  private
7
7
 
8
- def normalize_matchers(values, field:)
8
+ def normalize_matchers(values, field:, strict_exception_matchers: false)
9
9
  matchers = Array(values).flatten.compact
10
- matchers.each { |matcher| validate_matcher!(matcher, field: field) }
10
+ matchers.each do |matcher|
11
+ validate_matcher!(
12
+ matcher,
13
+ field: field,
14
+ strict_exception_matchers: strict_exception_matchers
15
+ )
16
+ end
11
17
  matchers
12
18
  end
13
19
 
14
- def validate_matcher!(matcher, field:)
15
- return if matcher.is_a?(Module) || matcher.is_a?(Regexp) || matcher.respond_to?(:call)
20
+ def validate_matcher!(matcher, field:, strict_exception_matchers:)
21
+ if matcher.is_a?(Module)
22
+ return unless strict_exception_matchers
23
+ return if matcher.is_a?(Class) && matcher <= Exception
24
+
25
+ raise ArgumentError,
26
+ "#{field} Module entries must be Exception classes when strict matcher validation is enabled"
27
+ end
28
+ return if matcher.is_a?(Regexp) || matcher.respond_to?(:call)
16
29
 
17
30
  raise ArgumentError, "#{field} entries must be Module, Regexp, or callable"
18
31
  end
@@ -1,7 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+
3
5
  module RSpec
4
6
  module Rewind
7
+ BudgetDecision = Struct.new(
8
+ :allowed,
9
+ :limit,
10
+ :used,
11
+ :remaining,
12
+ keyword_init: true
13
+ ) do
14
+ def allowed?
15
+ allowed
16
+ end
17
+ end
18
+
5
19
  class RetryBudget
6
20
  attr_reader :limit, :used
7
21
 
@@ -12,13 +26,17 @@ module RSpec
12
26
  end
13
27
 
14
28
  def consume!
15
- return true if unlimited?
29
+ consume.allowed?
30
+ end
31
+
32
+ def consume
33
+ return decision(true) if unlimited?
16
34
 
17
35
  @mutex.synchronize do
18
- return false if @used >= @limit
36
+ return decision(false) if @used >= @limit
19
37
 
20
38
  @used += 1
21
- true
39
+ decision(true)
22
40
  end
23
41
  end
24
42
 
@@ -32,8 +50,21 @@ module RSpec
32
50
  @limit.nil?
33
51
  end
34
52
 
53
+ def reset!
54
+ @mutex.synchronize { @used = 0 }
55
+ end
56
+
35
57
  private
36
58
 
59
+ def decision(allowed)
60
+ BudgetDecision.new(
61
+ allowed: allowed,
62
+ limit: @limit,
63
+ used: @used,
64
+ remaining: remaining
65
+ )
66
+ end
67
+
37
68
  def normalize_limit(limit)
38
69
  return nil if limit.nil?
39
70
 
@@ -48,5 +79,97 @@ module RSpec
48
79
  parsed
49
80
  end
50
81
  end
82
+
83
+ class FileRetryBudget
84
+ attr_reader :limit, :path
85
+
86
+ def initialize(limit:, path:)
87
+ @limit = normalize_limit(limit)
88
+ @path = path
89
+ end
90
+
91
+ def consume!
92
+ consume.allowed?
93
+ end
94
+
95
+ def consume
96
+ with_locked_counter do |used, file|
97
+ return decision(false, used) if used >= @limit
98
+
99
+ used += 1
100
+ write_used(file, used)
101
+ decision(true, used)
102
+ end
103
+ end
104
+
105
+ def used
106
+ with_locked_counter { |current| current }
107
+ end
108
+
109
+ def remaining
110
+ [@limit - used, 0].max
111
+ end
112
+
113
+ def unlimited?
114
+ false
115
+ end
116
+
117
+ def reset!
118
+ with_lock { |file| write_used(file, 0) }
119
+ end
120
+
121
+ private
122
+
123
+ def with_locked_counter
124
+ with_lock do |file|
125
+ current = read_used(file)
126
+ yield current, file
127
+ end
128
+ end
129
+
130
+ def with_lock
131
+ FileUtils.mkdir_p(File.dirname(@path))
132
+ File.open(@path, File::RDWR | File::CREAT, 0o644) do |file|
133
+ file.flock(File::LOCK_EX)
134
+ yield file
135
+ ensure
136
+ file&.flock(File::LOCK_UN)
137
+ end
138
+ end
139
+
140
+ def read_used(file)
141
+ file.rewind
142
+ text = file.read
143
+ Integer(text.empty? ? '0' : text)
144
+ end
145
+
146
+ def write_used(file, value)
147
+ file.rewind
148
+ file.truncate(0)
149
+ file.write(value.to_s)
150
+ file.flush
151
+ end
152
+
153
+ def decision(allowed, used)
154
+ BudgetDecision.new(
155
+ allowed: allowed,
156
+ limit: @limit,
157
+ used: used,
158
+ remaining: [@limit - used, 0].max
159
+ )
160
+ end
161
+
162
+ def normalize_limit(limit)
163
+ parsed = begin
164
+ Integer(limit)
165
+ rescue TypeError, ArgumentError
166
+ raise ArgumentError, 'file retry budget must be a non-negative integer'
167
+ end
168
+
169
+ raise ArgumentError, 'file retry budget must be >= 0' if parsed.negative?
170
+
171
+ parsed
172
+ end
173
+ end
51
174
  end
52
175
  end
@@ -4,6 +4,7 @@ module RSpec
4
4
  module Rewind
5
5
  class RetryCountResolver
6
6
  ENV_RETRIES_KEY = 'RSPEC_REWIND_RETRIES'
7
+ ENV_DISABLE_KEY = 'RSPEC_REWIND_DISABLE'
7
8
 
8
9
  def initialize(configuration:, metadata:)
9
10
  @configuration = configuration
@@ -11,8 +12,11 @@ module RSpec
11
12
  end
12
13
 
13
14
  def resolve(explicit_retries:)
14
- env_retries = ENV.fetch(ENV_RETRIES_KEY, nil)
15
- return parse_non_negative_integer(env_retries, source: ENV_RETRIES_KEY) if env_retries
15
+ return 0 if normalize_retry_override(explicit_retries) == 0 # rubocop:disable Style/NumericPredicate
16
+ return 0 if env_disabled?
17
+
18
+ env_retries = env_retries_value
19
+ return capped(parse_non_negative_integer(env_retries, source: ENV_RETRIES_KEY)) if env_retries
16
20
 
17
21
  configured = first_non_nil(
18
22
  normalize_retry_override(explicit_retries),
@@ -20,7 +24,7 @@ module RSpec
20
24
  @configuration.default_retries
21
25
  )
22
26
 
23
- parse_non_negative_integer(configured, source: 'retries')
27
+ capped(parse_non_negative_integer(configured, source: 'retries'))
24
28
  end
25
29
 
26
30
  private
@@ -46,6 +50,25 @@ module RSpec
46
50
  parsed
47
51
  end
48
52
 
53
+ def env_retries_value
54
+ value = ENV.fetch(ENV_RETRIES_KEY, nil)
55
+ return nil if value.nil? || value.to_s.empty?
56
+
57
+ value
58
+ end
59
+
60
+ def env_disabled?
61
+ value = ENV.fetch(ENV_DISABLE_KEY, nil)
62
+ %w[1 true yes on].include?(value.to_s.downcase)
63
+ end
64
+
65
+ def capped(value)
66
+ max_retries = @configuration.max_retries
67
+ return value if max_retries.nil? || value <= max_retries
68
+
69
+ raise ArgumentError, "retries must be <= #{max_retries}"
70
+ end
71
+
49
72
  def first_non_nil(*values)
50
73
  values.find { |value| !value.nil? }
51
74
  end
@@ -2,43 +2,132 @@
2
2
 
3
3
  module RSpec
4
4
  module Rewind
5
+ RetryDecisionResult = Struct.new(
6
+ :allowed,
7
+ :reason,
8
+ :matched_retry_on,
9
+ :matched_skip_retry_on,
10
+ :matcher_error,
11
+ keyword_init: true
12
+ ) do
13
+ def allowed?
14
+ allowed
15
+ end
16
+ end
17
+
18
+ RetryContext = Struct.new(
19
+ :attempt,
20
+ :retries,
21
+ :metadata,
22
+ :budget_remaining,
23
+ :failure_fingerprint,
24
+ :elapsed_time,
25
+ :sleep_total,
26
+ keyword_init: true
27
+ )
28
+
5
29
  class RetryDecision
6
- def initialize(exception:, example:, retry_on:, skip_retry_on:, retry_if:)
30
+ def initialize(
31
+ exception:,
32
+ example:,
33
+ retry_on:,
34
+ skip_retry_on:,
35
+ retry_if:,
36
+ retry_on_default: :all,
37
+ context: nil,
38
+ strict_callable_arity: false
39
+ )
7
40
  @exception = exception
8
41
  @example = example
9
42
  @retry_on = normalize_matchers(retry_on)
10
43
  @skip_retry_on = normalize_matchers(skip_retry_on)
11
44
  @retry_if = retry_if
45
+ @retry_on_default = retry_on_default
46
+ @context = context
47
+ @strict_callable_arity = strict_callable_arity
12
48
  end
13
49
 
14
50
  def retry?
15
- return false unless @exception
16
- return false if matches_any?(@skip_retry_on)
51
+ decision.allowed?
52
+ end
53
+
54
+ def decision
55
+ return rejected(:no_exception) unless @exception
56
+
57
+ skip_match = find_match(@skip_retry_on)
58
+ if skip_match.matched?
59
+ return rejected(
60
+ :skip_retry_on_matched,
61
+ matched_skip_retry_on: skip_match.description,
62
+ matcher_error: skip_match.error
63
+ )
64
+ end
17
65
 
18
- return false if @retry_on.any? && !matches_any?(@retry_on)
66
+ retry_match = nil
67
+ if @retry_on.any?
68
+ retry_match = find_match(@retry_on)
69
+ unless retry_match.matched?
70
+ return rejected(
71
+ :retry_on_not_matched,
72
+ matcher_error: retry_match.error
73
+ )
74
+ end
75
+ elsif !retry_on_default_allowed?
76
+ return rejected(:retry_on_default_rejected)
77
+ end
78
+
79
+ if @retry_if && !call_with_context(@retry_if)
80
+ return rejected(
81
+ :predicate_rejected,
82
+ matched_retry_on: retry_match&.description
83
+ )
84
+ end
19
85
 
20
- return true unless @retry_if
86
+ allowed(matched_retry_on: retry_match&.description)
87
+ end
21
88
 
22
- !!call_with_context(@retry_if)
89
+ MatchResult = Struct.new(:matched, :description, :error, keyword_init: true) do
90
+ def matched?
91
+ matched
92
+ end
23
93
  end
24
94
 
25
95
  private
26
96
 
27
- def matches_any?(matchers)
28
- matchers.any? { |matcher| match?(matcher) }
29
- end
97
+ def find_match(matchers)
98
+ last_error = nil
30
99
 
31
- def match?(matcher)
32
- case matcher
33
- when Module
34
- @exception.is_a?(matcher)
35
- when Regexp
36
- matcher.match?(@exception.message.to_s)
37
- else
38
- matcher.respond_to?(:call) && !!call_with_context(matcher)
100
+ matchers.each do |matcher|
101
+ result = match(matcher)
102
+ return result if result.matched?
103
+
104
+ last_error ||= result.error
39
105
  end
40
- rescue StandardError
41
- false
106
+
107
+ MatchResult.new(matched: false, error: last_error)
108
+ end
109
+
110
+ def match(matcher)
111
+ matched =
112
+ case matcher
113
+ when Module
114
+ @exception.is_a?(matcher)
115
+ when Regexp
116
+ matcher.match?(@exception.message.to_s)
117
+ else
118
+ matcher.respond_to?(:call) && !call_with_context(matcher).nil?
119
+ end
120
+
121
+ MatchResult.new(matched: matched, description: matcher_description(matcher))
122
+ rescue StandardError => e
123
+ MatchResult.new(matched: false, description: matcher_description(matcher), error: "#{e.class}: #{e.message}")
124
+ end
125
+
126
+ def retry_on_default_allowed?
127
+ return @exception.is_a?(StandardError) if @retry_on_default == :standard_errors
128
+ return false if @retry_on_default == :none
129
+
130
+ true
42
131
  end
43
132
 
44
133
  def call_with_context(callable)
@@ -46,7 +135,11 @@ module RSpec
46
135
  return callable.call if arity.zero?
47
136
 
48
137
  required = arity.negative? ? (-arity - 1) : arity
49
- args = [@exception, @example]
138
+ args = [@exception, @example, @context]
139
+ if @strict_callable_arity && arity.positive? && required > args.length
140
+ raise ArgumentError, "callable accepts #{required} required arguments; maximum supported is #{args.length}"
141
+ end
142
+
50
143
  args << nil while args.length < required
51
144
 
52
145
  if arity.positive?
@@ -65,6 +158,21 @@ module RSpec
65
158
  def normalize_matchers(values)
66
159
  Array(values).flatten.compact
67
160
  end
161
+
162
+ def allowed(**attributes)
163
+ RetryDecisionResult.new(allowed: true, reason: :allowed, **attributes)
164
+ end
165
+
166
+ def rejected(reason, **attributes)
167
+ RetryDecisionResult.new(allowed: false, reason: reason, **attributes)
168
+ end
169
+
170
+ def matcher_description(matcher)
171
+ return matcher.description if matcher.respond_to?(:description)
172
+ return matcher.name if matcher.respond_to?(:name) && matcher.name
173
+
174
+ matcher.inspect
175
+ end
68
176
  end
69
177
  end
70
178
  end
@@ -3,26 +3,60 @@
3
3
  module RSpec
4
4
  module Rewind
5
5
  class RetryDelayResolver
6
- def initialize(configuration:, metadata:, example:)
6
+ def initialize(configuration:, metadata:, example:, warn: ->(_message) {})
7
7
  @configuration = configuration
8
8
  @metadata = metadata || {}
9
9
  @example = example
10
+ @warn = warn
11
+ @delay_conflict_warned = false
10
12
  end
11
13
 
12
- def resolve(retry_number:, backoff:, wait:, exception:)
14
+ DelayContext = Struct.new(
15
+ :retry_number,
16
+ :resolved_retries,
17
+ :metadata,
18
+ :previous_sleep_seconds,
19
+ :failure_fingerprint,
20
+ keyword_init: true
21
+ )
22
+
23
+ def resolve(
24
+ retry_number:,
25
+ backoff:,
26
+ wait:,
27
+ exception:,
28
+ resolved_retries: nil,
29
+ previous_sleep_seconds: 0.0,
30
+ failure_fingerprint: nil
31
+ )
13
32
  explicit_wait = first_non_nil(wait, @metadata[:rewind_wait])
14
- return normalize_delay(explicit_wait) if explicit_wait
33
+ warn_delay_conflict(wait: wait, backoff: backoff)
34
+ return normalize_delay(explicit_wait) unless explicit_wait.nil?
15
35
 
16
36
  strategy = first_non_nil(backoff, @metadata[:rewind_backoff], @configuration.backoff)
17
37
  return normalize_delay(strategy) if strategy.is_a?(Numeric)
18
38
 
19
- return 0.0 unless strategy.respond_to?(:call)
39
+ unless strategy.respond_to?(:call)
40
+ raise ArgumentError,
41
+ 'backoff must be a non-negative numeric value or callable'
42
+ end
20
43
 
21
- raw = strategy.call(
44
+ args = {
22
45
  retry_number: retry_number,
23
46
  example: @example,
24
47
  exception: exception
25
- )
48
+ }
49
+ if accepts_keyword?(strategy, :context)
50
+ args[:context] = DelayContext.new(
51
+ retry_number: retry_number,
52
+ resolved_retries: resolved_retries,
53
+ metadata: @metadata,
54
+ previous_sleep_seconds: previous_sleep_seconds,
55
+ failure_fingerprint: failure_fingerprint
56
+ )
57
+ end
58
+
59
+ raw = strategy.call(**args)
26
60
 
27
61
  normalize_delay(raw)
28
62
  end
@@ -44,6 +78,23 @@ module RSpec
44
78
  def first_non_nil(*values)
45
79
  values.find { |value| !value.nil? }
46
80
  end
81
+
82
+ def warn_delay_conflict(wait:, backoff:)
83
+ return unless @configuration.warn_on_delay_conflict
84
+ return if @delay_conflict_warned
85
+ return unless !first_non_nil(wait, @metadata[:rewind_wait]).nil? &&
86
+ !first_non_nil(backoff, @metadata[:rewind_backoff]).nil?
87
+
88
+ @delay_conflict_warned = true
89
+ @warn.call('[rspec-rewind] wait and backoff are both configured; wait takes precedence')
90
+ end
91
+
92
+ def accepts_keyword?(callable, keyword)
93
+ parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
94
+ parameters.any? do |type, name|
95
+ type == :keyrest || (%i[key keyreq].include?(type) && name == keyword)
96
+ end
97
+ end
47
98
  end
48
99
  end
49
100
  end