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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa005ed6c1c90ab9f476de60e4ae3f34f09e98306cdc64794b9ff171e880d703
4
- data.tar.gz: ac9ac1e102d7b52cbe88ed25d21829ee94714d39a13b68ceaa7caa5ba4008b61
3
+ metadata.gz: df7affb556767810fad03faf4d94602e90e3f38af615e04742064acf7c1a95a9
4
+ data.tar.gz: 5d176192d59025af23f2509be6e2161bca3195c05215f7f4008ecc3e69bb187a
5
5
  SHA512:
6
- metadata.gz: 5cf553951d1d8de3b01c4dc81d9c17cb3b1add64b437e83f4289a906584ded67ce50f4d2664b596e2ad0f2f75251c8b71005587ac6f3df7d58f8999afd293d64
7
- data.tar.gz: ad6e9e857fd293dcc1e30dc2653b449ab12a7d96c4f2c3a8d2c0164187cd7ec198bfe0056eb9d357936cb164d2a9f552c02f7151ab91e231ec15348f282b8437
6
+ metadata.gz: 3ab4033f522b2bdd412113befc6a36470f2ea9847ac0b3c6a1e16c6aec8a4db6114b5fe712638be9ab6e0b52bb707b305c70214caf6a4e7c1a9fb4e7295a984c
7
+ data.tar.gz: 582d1716789ab6e374b682a9dc5b153e2815b57bddbcf6a018944c57acc65de1bfc4571c7beb55e623bcadc4051e9c908a37b5de8b4acb31b7748eb44b651f42
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ### Added
11
+
12
+ - Added `rspec/rewind/core` for loading the API without auto-installing the RSpec hook.
13
+ - Added `RSPEC_REWIND_AUTO_INSTALL` and `RSPEC_REWIND_DISABLE` controls.
14
+ - Added retry lifecycle hooks: `before_retry`, `after_retry`, and `not_retried_callback`.
15
+ - Added retry summary and flaky-threshold controls: `display_retry_summary`, `fail_on_flaky`, and `max_flaky_examples`.
16
+ - Added retry limits and dry-run controls: `max_retries`, `max_elapsed_time`, `max_total_sleep`, and `dry_run`.
17
+ - Added process-aware retry budgets with `RSpec::Rewind::FileRetryBudget`.
18
+ - Added richer retry events with decision reasons, matcher details, failure fingerprints, timing, sleep, budget, and selected metadata fields.
19
+ - Added `not_retried` and `reset_failed` event reporting, plus `reset_failure_policy`.
20
+ - Added advanced policy controls for `retry_if_mode`, `retry_on_default`, and metadata append/override modes.
21
+ - Added injectable jitter RNG support for exponential backoff.
22
+
23
+ ### Changed
24
+
25
+ - Retry event payloads are immutable.
26
+ - Runner configuration is snapshotted per example so later configuration changes do not affect in-flight retries.
27
+ - Flaky reporter path and reporter objects stay synchronized, and reporters are flushed and closed at suite end.
28
+
29
+ ### Fixed
30
+
31
+ - Clamp retry sleep before sleeping when `max_total_sleep` is configured.
32
+ - Prevent `not_retried_callback` errors from masking the original suite result.
33
+ - Keep reporter lifecycle failures from interrupting suite shutdown unless strict callbacks are enabled.
34
+ - Revalidate existing retry matchers when strict matcher validation is enabled.
35
+ - Validate exponential backoff jitter RNG output and keep jittered delays within the configured maximum.
36
+
10
37
  ## 0.1.0 (2026-02-07)
11
38
 
12
- - Initial release.
39
+ - Initial release.
data/README.md CHANGED
@@ -1,41 +1,15 @@
1
- <h1 align="center">rspec-rewind</h1>
2
-
3
- <p align="center">
4
- Deterministic retry orchestration for flaky RSpec examples.
5
- </p>
6
-
7
- <p align="center">
8
- <img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby Version">
9
- <img src="https://img.shields.io/badge/rspec--core-%3E%3D%203.12%2C%20%3C%204.0-brightgreen.svg" alt="RSpec Core Version">
10
- <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
11
- <a href="https://badge.fury.io/rb/rspec-rewind"><img src="https://badge.fury.io/rb/rspec-rewind.svg" alt="Gem Version" height="18"></a>
12
- <a href="https://github.com/ydah/rspec-rewind/actions/workflows/main.yml">
13
- <img src="https://github.com/ydah/rspec-rewind/actions/workflows/main.yml/badge.svg" alt="CI Status">
14
- </a>
15
- </p>
16
-
17
- <p align="center">
18
- <a href="#installation">Installation</a> •
19
- <a href="#quick-start">Quick Start</a> •
20
- <a href="#per-example-controls">Per-Example Controls</a> •
21
- <a href="#configuration">Configuration</a> •
22
- <a href="#observability">Observability</a> •
23
- <a href="#compatibility">Compatibility</a>
24
- </p>
25
-
26
- `rspec-rewind` is a modern retry gem for RSpec, inspired by [`rspec-retry`](https://github.com/NoRedInk/rspec-retry), with deterministic control and flaky-test observability for CI-heavy projects.
27
-
28
- ## Why rspec-rewind
29
-
30
- - `retries` always means "extra attempts" (not total attempts).
31
- - Retry filtering with `retry_on`, `skip_retry_on`, and `retry_if`.
32
- - Configurable delay via fixed, linear, exponential, or custom backoff.
33
- - Suite-level retry budget to prevent hidden retry inflation.
34
- - Flaky detection hooks and optional JSONL reporting.
1
+ # rspec-rewind
35
2
 
36
- ## Installation
3
+ Deterministic retry orchestration for flaky RSpec examples.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/rspec-rewind.svg)](https://badge.fury.io/rb/rspec-rewind)
6
+ [![CI](https://github.com/ydah/rspec-rewind/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/rspec-rewind/actions/workflows/main.yml)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
37
8
 
38
- Add to your Gemfile:
9
+ `rspec-rewind` retries RSpec examples with explicit retry counts, exception filters,
10
+ backoff strategies, retry budgets, and flaky-test reporting.
11
+
12
+ ## Installation
39
13
 
40
14
  ```ruby
41
15
  gem "rspec-rewind"
@@ -47,9 +21,10 @@ Then run:
47
21
  bundle install
48
22
  ```
49
23
 
50
- ## Quick Start
24
+ ## Usage
51
25
 
52
- `require "rspec/rewind"` installs an around hook automatically.
26
+ Require `rspec/rewind` from your RSpec setup file. This installs the around hook
27
+ automatically.
53
28
 
54
29
  ```ruby
55
30
  # spec/spec_helper.rb
@@ -57,15 +32,16 @@ require "rspec/rewind"
57
32
 
58
33
  RSpec::Rewind.configure do |config|
59
34
  config.default_retries = 1
60
- config.backoff = RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 1.0, jitter: 0.2)
61
35
  config.retry_on = [Net::ReadTimeout, Errno::ECONNRESET]
62
36
  config.skip_retry_on = [NoMethodError]
37
+ config.backoff = RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 1.0)
63
38
  config.retry_budget = 20
64
39
  config.flaky_report_path = "tmp/rspec-rewind/flaky.jsonl"
65
40
  end
66
41
  ```
67
42
 
68
- Basic per-example retry:
43
+ Retry counts are extra attempts. `rewind: 2` can run the example up to three
44
+ times total.
69
45
 
70
46
  ```ruby
71
47
  it "eventually becomes consistent", rewind: 2 do
@@ -76,7 +52,7 @@ end
76
52
  ## Per-Example Controls
77
53
 
78
54
  ```ruby
79
- it "uses metadata overrides",
55
+ it "retries transient API failures",
80
56
  rewind: 3,
81
57
  rewind_wait: 0.2,
82
58
  rewind_retry_on: [Net::ReadTimeout, /502/],
@@ -86,37 +62,19 @@ it "uses metadata overrides",
86
62
  end
87
63
  ```
88
64
 
89
- | Metadata key | Description |
65
+ | Key | Meaning |
90
66
  | --- | --- |
91
- | `rewind` | Retry count override. `true` = use default, `false` = disable retries for that example/group. |
92
- | `rewind_wait` | Fixed sleep before next attempt. |
93
- | `rewind_backoff` | Backoff strategy (numeric or callable). |
94
- | `rewind_retry_on` | Extra allow-list matchers. |
95
- | `rewind_skip_retry_on` | Extra deny-list matchers (checked first). |
96
- | `rewind_if` | Predicate `(exception)` or `(exception, example)` returning truthy/falsey. |
67
+ | `rewind` | Extra retry count. `true` uses the configured default; `false` disables retries. |
68
+ | `rewind_wait` | Fixed delay before the next attempt. |
69
+ | `rewind_backoff` | Numeric or callable backoff strategy. |
70
+ | `rewind_retry_on` | Additional retry allow-list matchers. |
71
+ | `rewind_skip_retry_on` | Additional retry deny-list matchers. Checked before allow-list matchers. |
72
+ | `rewind_if` | Predicate that decides whether the failure should retry. |
97
73
 
98
- Matcher types for `retry_on` and `skip_retry_on`: `Module`, `Regexp`, or callable.
74
+ `retry_on` and `skip_retry_on` matchers can be exception classes, regexps, or
75
+ callables.
99
76
 
100
- ## Configuration
101
-
102
- ```ruby
103
- RSpec::Rewind.configure do |config|
104
- config.default_retries = 0
105
- config.backoff = RSpec::Rewind::Backoff.fixed(0)
106
- config.retry_on = []
107
- config.skip_retry_on = []
108
- config.retry_if = nil
109
- config.retry_callback = nil
110
- config.flaky_callback = nil
111
- config.retry_budget = nil
112
- config.flaky_report_path = nil
113
- config.verbose = false
114
- config.display_retry_failure_messages = false
115
- config.clear_lets_on_failure = true
116
- end
117
- ```
118
-
119
- Backoff helpers:
77
+ ## Backoff
120
78
 
121
79
  ```ruby
122
80
  RSpec::Rewind::Backoff.fixed(0.2)
@@ -124,39 +82,18 @@ RSpec::Rewind::Backoff.linear(step: 0.1, max: 1.0)
124
82
  RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 2.0, jitter: 0.2)
125
83
  ```
126
84
 
127
- Environment override:
128
-
129
- ```bash
130
- RSPEC_REWIND_RETRIES=2 bundle exec rspec
131
- ```
132
-
133
- `RSPEC_REWIND_RETRIES` has highest priority over defaults and metadata.
134
-
135
- ## Retry Decision Order
136
-
137
- 1. Stop if no exception happened.
138
- 2. Stop if exception matches any `skip_retry_on`.
139
- 3. If `retry_on` is set, stop unless exception matches at least one matcher.
140
- 4. If `retry_if` exists, retry only when predicate returns truthy.
141
- 5. Stop if retry budget is exhausted.
142
-
143
85
  ## Observability
144
86
 
145
- ### Retry and Flaky Callbacks
87
+ Use callbacks when you want to send retry events to logs or metrics.
146
88
 
147
89
  ```ruby
148
90
  RSpec::Rewind.configure do |config|
149
- config.retry_callback = ->(event) do
150
- puts "[retry] #{event.example_id} attempt=#{event.attempt}/#{event.retries}"
151
- end
152
-
153
- config.flaky_callback = ->(event) do
154
- puts "[flaky] #{event.description} (attempt #{event.attempt})"
155
- end
91
+ config.retry_callback = ->(event) { warn "[retry] #{event.example_id} attempt=#{event.attempt}" }
92
+ config.flaky_callback = ->(event) { warn "[flaky] #{event.example_id}" }
156
93
  end
157
94
  ```
158
95
 
159
- ### JSONL Flaky Report
96
+ Set `flaky_report_path` to write flaky examples as JSONL:
160
97
 
161
98
  ```ruby
162
99
  RSpec::Rewind.configure do |config|
@@ -164,21 +101,14 @@ RSpec::Rewind.configure do |config|
164
101
  end
165
102
  ```
166
103
 
167
- Each flaky JSONL row includes:
168
-
169
- - `schema_version`
170
- - `status` (`flaky`)
171
- - `retry_reason`
172
- - `example_id`
173
- - `description`
174
- - `location`
175
- - `attempt`
176
- - `retries`
177
- - `exception_class`
178
- - `exception_message`
179
- - `duration`
180
- - `sleep_seconds`
181
- - `timestamp`
104
+ ## Environment
105
+
106
+ ```bash
107
+ RSPEC_REWIND_RETRIES=2 bundle exec rspec
108
+ RSPEC_REWIND_DISABLE=1 bundle exec rspec
109
+ ```
110
+
111
+ `RSPEC_REWIND_RETRIES` takes priority over configured defaults and metadata.
182
112
 
183
113
  ## Compatibility
184
114
 
@@ -188,24 +118,12 @@ Each flaky JSONL row includes:
188
118
  ## Development
189
119
 
190
120
  ```bash
191
- bundle exec rspec
121
+ bundle install
122
+ bundle exec rake spec
123
+ bundle exec rake rubocop
192
124
  bundle exec rake rbs
193
125
  ```
194
126
 
195
- CI validates:
196
-
197
- - Specs across Ruby 3.1, 3.2, 3.3, 3.4, 4.0, and head
198
- - Minimum compatibility with RSpec 3.12 (`BUNDLE_GEMFILE=gemfiles/rspec_3_12.gemfile`)
199
- - Type signatures (`rake rbs`)
200
- - Coverage threshold (`COVERAGE=1 rspec`)
201
- - Gem packaging (`rake build`)
202
- - Dependency security audit (`bundler-audit`)
203
-
204
- ## Contributing
205
-
206
- Bug reports and pull requests are welcome on GitHub:
207
- https://github.com/ydah/rspec-rewind
208
-
209
127
  ## License
210
128
 
211
129
  Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ PUBLIC_API = [
6
+ Backoff,
7
+ Configuration,
8
+ Event,
9
+ FileRetryBudget,
10
+ FlakyReporter,
11
+ RetryBudget,
12
+ RetryDecision,
13
+ RetryPolicy,
14
+ Runner
15
+ ].freeze
16
+
17
+ INTERNAL_API = [
18
+ AttemptRunner,
19
+ ExampleContext,
20
+ ExampleMethods,
21
+ ExampleStateResetter,
22
+ FailureFingerprint,
23
+ FlakyTransition,
24
+ RetryCountResolver,
25
+ RetryDelayResolver,
26
+ RetryEventBuilder,
27
+ RetryGate,
28
+ RetryLoop,
29
+ RetryNotifier,
30
+ RetryTransition,
31
+ RunnerComponentFactory,
32
+ RunnerComponents,
33
+ RunnerLogger,
34
+ RSpecAdapter
35
+ ].freeze
36
+ end
37
+ end
@@ -11,6 +11,10 @@ module RSpec
11
11
  SecurityError
12
12
  ].freeze
13
13
 
14
+ def initialize(clock: nil)
15
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
16
+ end
17
+
14
18
  def run(run_target:, exception_source:)
15
19
  started_at = monotonic_time
16
20
 
@@ -18,7 +22,8 @@ module RSpec
18
22
  run_target.run
19
23
  duration = monotonic_time - started_at
20
24
  [current_exception(exception_source), duration, false]
21
- rescue Exception => e
25
+ # RSpec expectation failures inherit from Exception, so fatal exceptions are filtered explicitly.
26
+ rescue Exception => e # rubocop:disable Lint/RescueException
22
27
  raise if fatal_exception?(e)
23
28
 
24
29
  duration = monotonic_time - started_at
@@ -29,7 +34,7 @@ module RSpec
29
34
  private
30
35
 
31
36
  def monotonic_time
32
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ @clock.call
33
38
  end
34
39
 
35
40
  def current_exception(exception_source)
@@ -20,11 +20,13 @@ module RSpec
20
20
  end
21
21
  end
22
22
 
23
- def exponential(base:, factor: 2.0, max: nil, jitter: 0.0)
23
+ def exponential(base:, factor: 2.0, max: nil, jitter: 0.0, rng: Kernel, min_factor: 0.0)
24
24
  base_value = normalize_numeric(base, 'base')
25
25
  factor_value = normalize_numeric(factor, 'factor')
26
+ min_factor_value = normalize_numeric(min_factor, 'min_factor')
26
27
  jitter_value = normalize_numeric(jitter, 'jitter')
27
28
  max_value = max.nil? ? nil : normalize_numeric(max, 'max')
29
+ raise ArgumentError, "factor must be >= #{min_factor_value}" if factor_value < min_factor_value
28
30
 
29
31
  lambda do |retry_number:, **_|
30
32
  exponent = [retry_number.to_i - 1, 0].max
@@ -36,7 +38,7 @@ module RSpec
36
38
  spread = delay * jitter_value
37
39
  min_delay = [delay - spread, 0.0].max
38
40
  max_delay = delay + spread
39
- (Kernel.rand * (max_delay - min_delay)) + min_delay
41
+ clamp((random_value(rng) * (max_delay - min_delay)) + min_delay, max_value)
40
42
  end
41
43
  end
42
44
 
@@ -59,6 +61,28 @@ module RSpec
59
61
  number
60
62
  end
61
63
  private_class_method :normalize_numeric
64
+
65
+ def random_value(rng)
66
+ raw =
67
+ if rng.respond_to?(:rand)
68
+ rng.rand
69
+ elsif rng.respond_to?(:call)
70
+ rng.call
71
+ else
72
+ raise ArgumentError, 'rng must respond to #rand or #call'
73
+ end
74
+
75
+ value = begin
76
+ Float(raw)
77
+ rescue TypeError, ArgumentError
78
+ raise ArgumentError, 'rng must return a numeric value'
79
+ end
80
+
81
+ raise ArgumentError, 'rng must return a value between 0 and 1' unless value.between?(0.0, 1.0)
82
+
83
+ value
84
+ end
85
+ private_class_method :random_value
62
86
  end
63
87
  end
64
88
  end