rspec-rewind 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/lib/rspec/rewind/attempt_runner.rb +46 -0
- data/lib/rspec/rewind/backoff.rb +64 -0
- data/lib/rspec/rewind/configuration.rb +125 -0
- data/lib/rspec/rewind/event.rb +24 -0
- data/lib/rspec/rewind/example_context.rb +27 -0
- data/lib/rspec/rewind/example_methods.rb +30 -0
- data/lib/rspec/rewind/example_state_resetter.rb +57 -0
- data/lib/rspec/rewind/flaky_reporter.rb +54 -0
- data/lib/rspec/rewind/flaky_transition.rb +26 -0
- data/lib/rspec/rewind/matcher_validation.rb +21 -0
- data/lib/rspec/rewind/retry_budget.rb +52 -0
- data/lib/rspec/rewind/retry_count_resolver.rb +54 -0
- data/lib/rspec/rewind/retry_decision.rb +70 -0
- data/lib/rspec/rewind/retry_delay_resolver.rb +49 -0
- data/lib/rspec/rewind/retry_event_builder.rb +29 -0
- data/lib/rspec/rewind/retry_gate.rb +43 -0
- data/lib/rspec/rewind/retry_loop.rb +72 -0
- data/lib/rspec/rewind/retry_notifier.rb +47 -0
- data/lib/rspec/rewind/retry_policy.rb +51 -0
- data/lib/rspec/rewind/retry_transition.rb +40 -0
- data/lib/rspec/rewind/runner.rb +36 -0
- data/lib/rspec/rewind/runner_components.rb +66 -0
- data/lib/rspec/rewind/runner_logger.rb +27 -0
- data/lib/rspec/rewind/version.rb +7 -0
- data/lib/rspec/rewind.rb +68 -0
- data/lib/rspec-rewind.rb +3 -0
- data/rspec-rewind.gemspec +38 -0
- data/sig/rspec/rewind.rbs +211 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fa005ed6c1c90ab9f476de60e4ae3f34f09e98306cdc64794b9ff171e880d703
|
|
4
|
+
data.tar.gz: ac9ac1e102d7b52cbe88ed25d21829ee94714d39a13b68ceaa7caa5ba4008b61
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5cf553951d1d8de3b01c4dc81d9c17cb3b1add64b437e83f4289a906584ded67ce50f4d2664b596e2ad0f2f75251c8b71005587ac6f3df7d58f8999afd293d64
|
|
7
|
+
data.tar.gz: ad6e9e857fd293dcc1e30dc2653b449ab12a7d96c4f2c3a8d2c0164187cd7ec198bfe0056eb9d357936cb164d2a9f552c02f7151ab91e231ec15348f282b8437
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.1.0 (2026-02-07)
|
|
11
|
+
|
|
12
|
+
- Initial release.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
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.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add to your Gemfile:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "rspec-rewind"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then run:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bundle install
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
`require "rspec/rewind"` installs an around hook automatically.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# spec/spec_helper.rb
|
|
56
|
+
require "rspec/rewind"
|
|
57
|
+
|
|
58
|
+
RSpec::Rewind.configure do |config|
|
|
59
|
+
config.default_retries = 1
|
|
60
|
+
config.backoff = RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 1.0, jitter: 0.2)
|
|
61
|
+
config.retry_on = [Net::ReadTimeout, Errno::ECONNRESET]
|
|
62
|
+
config.skip_retry_on = [NoMethodError]
|
|
63
|
+
config.retry_budget = 20
|
|
64
|
+
config.flaky_report_path = "tmp/rspec-rewind/flaky.jsonl"
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Basic per-example retry:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
it "eventually becomes consistent", rewind: 2 do
|
|
72
|
+
expect(fetch_remote_state).to eq("ready")
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Per-Example Controls
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
it "uses metadata overrides",
|
|
80
|
+
rewind: 3,
|
|
81
|
+
rewind_wait: 0.2,
|
|
82
|
+
rewind_retry_on: [Net::ReadTimeout, /502/],
|
|
83
|
+
rewind_skip_retry_on: [NoMethodError],
|
|
84
|
+
rewind_if: ->(exception, _example) { exception.message.include?("gateway") } do
|
|
85
|
+
expect(call_api).to eq(:ok)
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Metadata key | Description |
|
|
90
|
+
| --- | --- |
|
|
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. |
|
|
97
|
+
|
|
98
|
+
Matcher types for `retry_on` and `skip_retry_on`: `Module`, `Regexp`, or callable.
|
|
99
|
+
|
|
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:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
RSpec::Rewind::Backoff.fixed(0.2)
|
|
123
|
+
RSpec::Rewind::Backoff.linear(step: 0.1, max: 1.0)
|
|
124
|
+
RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 2.0, jitter: 0.2)
|
|
125
|
+
```
|
|
126
|
+
|
|
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
|
+
## Observability
|
|
144
|
+
|
|
145
|
+
### Retry and Flaky Callbacks
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
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
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### JSONL Flaky Report
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
RSpec::Rewind.configure do |config|
|
|
163
|
+
config.flaky_report_path = "tmp/rspec-rewind/flaky.jsonl"
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
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`
|
|
182
|
+
|
|
183
|
+
## Compatibility
|
|
184
|
+
|
|
185
|
+
- Ruby `>= 3.1`
|
|
186
|
+
- RSpec Core `>= 3.12`, `< 4.0`
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
bundle exec rspec
|
|
192
|
+
bundle exec rake rbs
|
|
193
|
+
```
|
|
194
|
+
|
|
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
|
+
## License
|
|
210
|
+
|
|
211
|
+
Released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
class AttemptRunner
|
|
6
|
+
FATAL_EXCEPTIONS = [
|
|
7
|
+
NoMemoryError,
|
|
8
|
+
ScriptError,
|
|
9
|
+
SignalException,
|
|
10
|
+
SystemExit,
|
|
11
|
+
SecurityError
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def run(run_target:, exception_source:)
|
|
15
|
+
started_at = monotonic_time
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
run_target.run
|
|
19
|
+
duration = monotonic_time - started_at
|
|
20
|
+
[current_exception(exception_source), duration, false]
|
|
21
|
+
rescue Exception => e
|
|
22
|
+
raise if fatal_exception?(e)
|
|
23
|
+
|
|
24
|
+
duration = monotonic_time - started_at
|
|
25
|
+
[e, duration, true]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def monotonic_time
|
|
32
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def current_exception(exception_source)
|
|
36
|
+
return nil unless exception_source.respond_to?(:exception)
|
|
37
|
+
|
|
38
|
+
exception_source.exception
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fatal_exception?(exception)
|
|
42
|
+
FATAL_EXCEPTIONS.any? { |klass| exception.is_a?(klass) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
module Backoff
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def fixed(seconds)
|
|
9
|
+
value = normalize_numeric(seconds, 'seconds')
|
|
10
|
+
->(**_) { value }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def linear(step:, max: nil)
|
|
14
|
+
step_value = normalize_numeric(step, 'step')
|
|
15
|
+
max_value = max.nil? ? nil : normalize_numeric(max, 'max')
|
|
16
|
+
|
|
17
|
+
lambda do |retry_number:, **_|
|
|
18
|
+
delay = step_value * retry_number.to_i
|
|
19
|
+
clamp(delay, max_value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def exponential(base:, factor: 2.0, max: nil, jitter: 0.0)
|
|
24
|
+
base_value = normalize_numeric(base, 'base')
|
|
25
|
+
factor_value = normalize_numeric(factor, 'factor')
|
|
26
|
+
jitter_value = normalize_numeric(jitter, 'jitter')
|
|
27
|
+
max_value = max.nil? ? nil : normalize_numeric(max, 'max')
|
|
28
|
+
|
|
29
|
+
lambda do |retry_number:, **_|
|
|
30
|
+
exponent = [retry_number.to_i - 1, 0].max
|
|
31
|
+
delay = base_value * (factor_value**exponent)
|
|
32
|
+
delay = clamp(delay, max_value)
|
|
33
|
+
|
|
34
|
+
next delay if jitter_value.zero?
|
|
35
|
+
|
|
36
|
+
spread = delay * jitter_value
|
|
37
|
+
min_delay = [delay - spread, 0.0].max
|
|
38
|
+
max_delay = delay + spread
|
|
39
|
+
(Kernel.rand * (max_delay - min_delay)) + min_delay
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clamp(value, max)
|
|
44
|
+
return value unless max
|
|
45
|
+
|
|
46
|
+
[value, max].min
|
|
47
|
+
end
|
|
48
|
+
private_class_method :clamp
|
|
49
|
+
|
|
50
|
+
def normalize_numeric(value, name)
|
|
51
|
+
number = begin
|
|
52
|
+
Float(value)
|
|
53
|
+
rescue TypeError, ArgumentError
|
|
54
|
+
raise ArgumentError, "#{name} must be a numeric value"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "#{name} must be >= 0" if number.negative?
|
|
58
|
+
|
|
59
|
+
number
|
|
60
|
+
end
|
|
61
|
+
private_class_method :normalize_numeric
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
class Configuration
|
|
6
|
+
include MatcherValidation
|
|
7
|
+
|
|
8
|
+
attr_reader :default_retries, :backoff, :retry_on, :skip_retry_on, :retry_if, :retry_callback, :flaky_callback,
|
|
9
|
+
:verbose, :display_retry_failure_messages, :clear_lets_on_failure, :retry_budget, :flaky_reporter
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
self.default_retries = 0
|
|
13
|
+
self.backoff = Backoff.fixed(0)
|
|
14
|
+
self.retry_on = []
|
|
15
|
+
self.skip_retry_on = []
|
|
16
|
+
self.retry_if = nil
|
|
17
|
+
self.retry_callback = nil
|
|
18
|
+
self.flaky_callback = nil
|
|
19
|
+
self.verbose = false
|
|
20
|
+
self.display_retry_failure_messages = false
|
|
21
|
+
self.clear_lets_on_failure = true
|
|
22
|
+
self.retry_budget = nil
|
|
23
|
+
self.flaky_reporter = FlakyReporter.null
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def default_retries=(value)
|
|
27
|
+
@default_retries = parse_non_negative_integer(value, source: 'default_retries')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def backoff=(value)
|
|
31
|
+
@backoff = normalize_backoff(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def retry_on=(values)
|
|
35
|
+
@retry_on = normalize_matchers(values, field: 'retry_on')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def skip_retry_on=(values)
|
|
39
|
+
@skip_retry_on = normalize_matchers(values, field: 'skip_retry_on')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def retry_if=(callable)
|
|
43
|
+
@retry_if = normalize_callable(callable, field: 'retry_if')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def retry_callback=(callable)
|
|
47
|
+
@retry_callback = normalize_callable(callable, field: 'retry_callback')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def flaky_callback=(callable)
|
|
51
|
+
@flaky_callback = normalize_callable(callable, field: 'flaky_callback')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def verbose=(value)
|
|
55
|
+
@verbose = normalize_boolean(value, field: 'verbose')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def display_retry_failure_messages=(value)
|
|
59
|
+
@display_retry_failure_messages = normalize_boolean(value, field: 'display_retry_failure_messages')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear_lets_on_failure=(value)
|
|
63
|
+
@clear_lets_on_failure = normalize_boolean(value, field: 'clear_lets_on_failure')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def retry_budget=(limit_or_budget)
|
|
67
|
+
@retry_budget =
|
|
68
|
+
if limit_or_budget.is_a?(RetryBudget)
|
|
69
|
+
limit_or_budget
|
|
70
|
+
else
|
|
71
|
+
RetryBudget.new(limit_or_budget)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def flaky_reporter=(reporter)
|
|
76
|
+
@flaky_reporter = reporter || FlakyReporter.null
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def flaky_report_path=(path)
|
|
80
|
+
@flaky_reporter = path.nil? ? FlakyReporter.null : FlakyReporter.jsonl(path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def parse_non_negative_integer(value, source:)
|
|
86
|
+
parsed = begin
|
|
87
|
+
Integer(value)
|
|
88
|
+
rescue TypeError, ArgumentError
|
|
89
|
+
raise ArgumentError, "#{source} must be a non-negative integer"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "#{source} must be >= 0" if parsed.negative?
|
|
93
|
+
|
|
94
|
+
parsed
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_backoff(value)
|
|
98
|
+
if value.is_a?(Numeric)
|
|
99
|
+
number = Float(value)
|
|
100
|
+
raise ArgumentError, 'backoff must be >= 0' if number.negative?
|
|
101
|
+
|
|
102
|
+
return number
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
return value if value.respond_to?(:call)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, 'backoff must be a non-negative numeric value or callable'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_callable(callable, field:)
|
|
111
|
+
return nil if callable.nil?
|
|
112
|
+
|
|
113
|
+
return callable if callable.respond_to?(:call)
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "#{field} must be nil or callable"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_boolean(value, field:)
|
|
119
|
+
return value if [true, false].include?(value)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "#{field} must be true or false"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
EVENT_SCHEMA_VERSION = 1
|
|
6
|
+
|
|
7
|
+
Event = Struct.new(
|
|
8
|
+
:schema_version,
|
|
9
|
+
:status,
|
|
10
|
+
:retry_reason,
|
|
11
|
+
:example_id,
|
|
12
|
+
:description,
|
|
13
|
+
:location,
|
|
14
|
+
:attempt,
|
|
15
|
+
:retries,
|
|
16
|
+
:exception_class,
|
|
17
|
+
:exception_message,
|
|
18
|
+
:duration,
|
|
19
|
+
:sleep_seconds,
|
|
20
|
+
:timestamp,
|
|
21
|
+
keyword_init: true
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
class ExampleContext
|
|
6
|
+
def initialize(example:)
|
|
7
|
+
@example = example
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def source
|
|
11
|
+
return @example.example if @example.respond_to?(:example) && @example.example
|
|
12
|
+
|
|
13
|
+
@example
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def metadata
|
|
17
|
+
return {} unless source.respond_to?(:metadata)
|
|
18
|
+
|
|
19
|
+
source.metadata || {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def id
|
|
23
|
+
source.respond_to?(:id) ? source.id : 'unknown'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
module ExampleMethods
|
|
6
|
+
def run_with_rewind(options = {})
|
|
7
|
+
normalized = normalize_options(options)
|
|
8
|
+
|
|
9
|
+
Runner.new(example: self, configuration: RSpec::Rewind.configuration).run(**normalized)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def normalize_options(options)
|
|
15
|
+
return {} if options.nil? || options.empty?
|
|
16
|
+
|
|
17
|
+
symbolized = options.transform_keys(&:to_sym)
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
retries: symbolized[:retries],
|
|
21
|
+
backoff: symbolized[:backoff],
|
|
22
|
+
wait: symbolized[:wait],
|
|
23
|
+
retry_on: symbolized[:retry_on],
|
|
24
|
+
skip_retry_on: symbolized[:skip_retry_on],
|
|
25
|
+
retry_if: symbolized[:retry_if]
|
|
26
|
+
}.compact
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
class ExampleStateResetter
|
|
6
|
+
def initialize(configuration:)
|
|
7
|
+
@configuration = configuration
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def reset(example_source)
|
|
11
|
+
clear_exception(example_source)
|
|
12
|
+
clear_execution_result(example_source)
|
|
13
|
+
clear_lets(example_source) if @configuration.clear_lets_on_failure
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def clear_exception(example_source)
|
|
19
|
+
if example_source.respond_to?(:clear_exception)
|
|
20
|
+
example_source.clear_exception
|
|
21
|
+
elsif example_source.instance_variable_defined?(:@exception)
|
|
22
|
+
example_source.instance_variable_set(:@exception, nil)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear_execution_result(example_source)
|
|
27
|
+
return unless example_source.respond_to?(:execution_result)
|
|
28
|
+
|
|
29
|
+
result = example_source.execution_result
|
|
30
|
+
return unless result
|
|
31
|
+
|
|
32
|
+
set_if_writer(result, :status, nil)
|
|
33
|
+
set_if_writer(result, :exception, nil)
|
|
34
|
+
set_if_writer(result, :pending_message, nil)
|
|
35
|
+
set_if_writer(result, :run_time, nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear_lets(example_source)
|
|
39
|
+
return unless example_source.respond_to?(:example_group_instance)
|
|
40
|
+
|
|
41
|
+
group_instance = example_source.example_group_instance
|
|
42
|
+
return unless group_instance
|
|
43
|
+
|
|
44
|
+
if group_instance.respond_to?(:clear_lets)
|
|
45
|
+
group_instance.clear_lets
|
|
46
|
+
elsif group_instance.instance_variable_defined?(:@__memoized)
|
|
47
|
+
group_instance.instance_variable_set(:@__memoized, nil)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_if_writer(target, attribute, value)
|
|
52
|
+
writer = "#{attribute}="
|
|
53
|
+
target.public_send(writer, value) if target.respond_to?(writer)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Rewind
|
|
8
|
+
class FlakyReporter
|
|
9
|
+
class << self
|
|
10
|
+
def null
|
|
11
|
+
@null ||= NullReporter.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def jsonl(path)
|
|
15
|
+
JsonlReporter.new(path)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class NullReporter
|
|
20
|
+
def record(_event); end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class JsonlReporter
|
|
24
|
+
def initialize(path)
|
|
25
|
+
@path = path
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
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
|
+
}
|
|
45
|
+
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
48
|
+
File.open(@path, 'a') { |file| file.puts(JSON.generate(payload)) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|