rspec-flake-classifier 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/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +8 -0
- data/exe/rspec-flake +6 -0
- data/lib/rspec/flake/classifier/classify/classifier.rb +228 -0
- data/lib/rspec/flake/classifier/classify/context.rb +41 -0
- data/lib/rspec/flake/classifier/classify/result.rb +44 -0
- data/lib/rspec/flake/classifier/cli.rb +298 -0
- data/lib/rspec/flake/classifier/configuration.rb +40 -0
- data/lib/rspec/flake/classifier/coverage_snapshot.rb +89 -0
- data/lib/rspec/flake/classifier/deflaker.rb +102 -0
- data/lib/rspec/flake/classifier/evaluation.rb +127 -0
- data/lib/rspec/flake/classifier/example_history.rb +24 -0
- data/lib/rspec/flake/classifier/features.rb +42 -0
- data/lib/rspec/flake/classifier/formatter.rb +194 -0
- data/lib/rspec/flake/classifier/integrations.rb +247 -0
- data/lib/rspec/flake/classifier/predictor.rb +144 -0
- data/lib/rspec/flake/classifier/probe_evidence.rb +77 -0
- data/lib/rspec/flake/classifier/rerun/bisect_dependency_search.rb +81 -0
- data/lib/rspec/flake/classifier/rerun/isolated_runner.rb +69 -0
- data/lib/rspec/flake/classifier/rerun/protocol.rb +83 -0
- data/lib/rspec/flake/classifier/rerun/result.rb +82 -0
- data/lib/rspec/flake/classifier/runtime_controls.rb +63 -0
- data/lib/rspec/flake/classifier/sensitivity.rb +82 -0
- data/lib/rspec/flake/classifier/signature.rb +59 -0
- data/lib/rspec/flake/classifier/store/jsonl_store.rb +131 -0
- data/lib/rspec/flake/classifier/version.rb +13 -0
- data/lib/rspec/flake/classifier.rb +285 -0
- data/sig/rspec/flake/classifier.rbs +176 -0
- metadata +135 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 858a4f181049ee08fcd65482a9d162b5c9cb299be9492058d3385625e495a8bc
|
|
4
|
+
data.tar.gz: c8331443bacc0c9f94f09b3bf7a887321a5fe72eb6669ee89eafc3288cc312e5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ed88a38ce6d5acaaec24ef6d786cbe437b26f503cf916062f8dd811ff3b4be780f4d4ad7bc29c7bb4b9f5f045f91172f93f03ff9e2a669ffaa3c9c71d2989327
|
|
7
|
+
data.tar.gz: f83d5bc547db34d0eb1695911f99af512e3eec2b3e69833e578870bbd313b61703e0a721227d782ac160e3b11c684561e5675e8e484facaf2191e835c74b2525
|
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,301 @@
|
|
|
1
|
+
# rspec-flake-classifier
|
|
2
|
+
|
|
3
|
+
`rspec-flake-classifier` records failed RSpec examples, normalizes their failure
|
|
4
|
+
signatures, and classifies likely flaky-test causes. It can also rerun examples in
|
|
5
|
+
isolated RSpec subprocesses to separate repeatable regressions from flaky failures.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add this line to your application's Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "rspec-flake-classifier"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
And then execute:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install it yourself as:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install rspec-flake-classifier
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requirements:
|
|
28
|
+
|
|
29
|
+
- Ruby 3.2 or newer
|
|
30
|
+
- RSpec 3.10 or newer
|
|
31
|
+
|
|
32
|
+
Runtime dependencies:
|
|
33
|
+
|
|
34
|
+
- `rspec-core`
|
|
35
|
+
- `rspec-covers`
|
|
36
|
+
- `rspec-hermetic`
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
Configure it from `spec/spec_helper.rb` or `spec/rails_helper.rb`:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "rspec/flake/classifier"
|
|
44
|
+
|
|
45
|
+
RSpec.configure do |config|
|
|
46
|
+
RSpec::FlakeClassifier.configure(config) do |flake|
|
|
47
|
+
flake.store = ".rspec_flake_store"
|
|
48
|
+
flake.auto_rerun = false
|
|
49
|
+
flake.deflaker = true
|
|
50
|
+
flake.run_sensitivity = false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Run your suite normally:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bundle exec rspec
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Failure records are written to `.rspec_flake_store/failures.jsonl`. Each record
|
|
62
|
+
contains a normalized signature digest, occurrence counts, labels, example IDs,
|
|
63
|
+
and evidence metadata.
|
|
64
|
+
|
|
65
|
+
### Features
|
|
66
|
+
|
|
67
|
+
- Append-only JSONL failure signature store
|
|
68
|
+
- iDFlakies-style rerun protocol for same-order, isolated, OD, and NOD checks
|
|
69
|
+
- Luo taxonomy cause labels such as `network`, `time`, `io`, `resource_leak`,
|
|
70
|
+
`async_wait`, `randomness`, and `test_order_dependency`
|
|
71
|
+
- DeFlaker-style coverage and git diff suspicion signal
|
|
72
|
+
- Runtime integration with `rspec-covers` and `rspec-hermetic`
|
|
73
|
+
- JSON and JUnit formatters for CI ingestion
|
|
74
|
+
- Buildkite Ruby test collector tags when the collector is active
|
|
75
|
+
- Lightweight feature extraction, prediction, training, and evaluation commands
|
|
76
|
+
|
|
77
|
+
### Configuration
|
|
78
|
+
|
|
79
|
+
Common options:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
RSpec::FlakeClassifier.configure(config) do |flake|
|
|
83
|
+
flake.store = ".rspec_flake_store"
|
|
84
|
+
flake.auto_rerun = { failures: 3 }
|
|
85
|
+
flake.rspec_command = ["bundle", "exec", "rspec"]
|
|
86
|
+
flake.deflaker = true
|
|
87
|
+
flake.changed_lines_provider = -> { { "app/user.rb" => [10, 11] } }
|
|
88
|
+
flake.coverage_provider = ->(example) { example.metadata[:covered_lines] }
|
|
89
|
+
flake.probe_provider = ->(_example) { { files: ["tmp/cache"] } }
|
|
90
|
+
flake.skip_known_flakes = false
|
|
91
|
+
flake.run_sensitivity = false
|
|
92
|
+
flake.sensitivity_factors = %i[time randomness network]
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`auto_rerun` starts isolated subprocess reruns for failed examples. Keep it off
|
|
97
|
+
unless you are comfortable with the extra runtime cost in CI.
|
|
98
|
+
|
|
99
|
+
### rspec-covers and rspec-hermetic
|
|
100
|
+
|
|
101
|
+
When all three gems are enabled, configure `rspec-covers` and `rspec-hermetic`
|
|
102
|
+
before `rspec-flake-classifier`. That lets the classifier observe their
|
|
103
|
+
after-example records from its outer RSpec hook.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
require "rspec/covers"
|
|
107
|
+
require "rspec/hermetic"
|
|
108
|
+
require "rspec/flake/classifier"
|
|
109
|
+
|
|
110
|
+
RSpec.configure do |config|
|
|
111
|
+
RSpec::Covers.configure(config) do |covers|
|
|
112
|
+
covers.root = Dir.pwd
|
|
113
|
+
covers.production_paths = %w[app lib]
|
|
114
|
+
covers.risky = :report
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
RSpec::Hermetic.configure(config) do |hermetic|
|
|
118
|
+
hermetic.root_path = Dir.pwd
|
|
119
|
+
hermetic.probes = %i[filesystem resources]
|
|
120
|
+
hermetic.on_pollution = :report
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
RSpec::FlakeClassifier.configure(config) do |flake|
|
|
124
|
+
flake.store = ".rspec_flake_store"
|
|
125
|
+
flake.deflaker = true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The classifier reads executed locations from `RSpec::Covers.reporter.results`
|
|
131
|
+
and filesystem or resource changes from `RSpec::Hermetic.runner.records`. It also
|
|
132
|
+
accepts compatible third-party adapters and explicit metadata such as
|
|
133
|
+
`:flake_classifier_coverage`, `:flake_classifier_files`,
|
|
134
|
+
`:flake_classifier_resources`, and `:flake_classifier_sockets`.
|
|
135
|
+
|
|
136
|
+
### CI output
|
|
137
|
+
|
|
138
|
+
Require the formatter explicitly when you want machine-readable output:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
bundle exec rspec \
|
|
142
|
+
--require rspec/flake/classifier/formatter \
|
|
143
|
+
--format progress \
|
|
144
|
+
--format RSpec::FlakeClassifier::Formatter \
|
|
145
|
+
--out tmp/rspec-flakes.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
For JUnit XML:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
bundle exec rspec \
|
|
152
|
+
--require rspec/flake/classifier/formatter \
|
|
153
|
+
--format progress \
|
|
154
|
+
--format RSpec::FlakeClassifier::JUnitFormatter \
|
|
155
|
+
--out test-results/rspec/rspec.xml
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The JUnit formatter adds `flaky`, `flaky_labels`, `flake_signature`,
|
|
159
|
+
`buildkite.*`, and `circleci.*` metadata.
|
|
160
|
+
|
|
161
|
+
### Buildkite
|
|
162
|
+
|
|
163
|
+
Use the official Buildkite Ruby test collector as usual:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
require "buildkite/test_collector"
|
|
167
|
+
|
|
168
|
+
Buildkite::TestCollector.configure(hook: :rspec)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When the collector is active, `rspec-flake-classifier` tags failed executions
|
|
172
|
+
with:
|
|
173
|
+
|
|
174
|
+
- `rspec_flake_classifier.flaky`
|
|
175
|
+
- `rspec_flake_classifier.labels`
|
|
176
|
+
- `rspec_flake_classifier.signature`
|
|
177
|
+
|
|
178
|
+
### CircleCI
|
|
179
|
+
|
|
180
|
+
Generate JUnit XML and store the results directory:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
version: 2.1
|
|
184
|
+
|
|
185
|
+
jobs:
|
|
186
|
+
test:
|
|
187
|
+
docker:
|
|
188
|
+
- image: cimg/ruby:3.3
|
|
189
|
+
steps:
|
|
190
|
+
- checkout
|
|
191
|
+
- run: bundle install
|
|
192
|
+
- run:
|
|
193
|
+
name: Run RSpec
|
|
194
|
+
command: |
|
|
195
|
+
bundle exec rspec \
|
|
196
|
+
--require rspec/flake/classifier/formatter \
|
|
197
|
+
--format progress \
|
|
198
|
+
--format RSpec::FlakeClassifier::JUnitFormatter \
|
|
199
|
+
--out test-results/rspec/rspec.xml
|
|
200
|
+
- store_test_results:
|
|
201
|
+
path: test-results
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### CLI
|
|
205
|
+
|
|
206
|
+
Investigate a failed example:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
rspec-flake investigate 'spec/models/user_spec.rb[1:2]' --seed 1234 --prior 'spec/models/user_spec.rb[1:1]'
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Classify a message or existing store records:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
rspec-flake classify "Net::HTTP timed out" --json
|
|
216
|
+
rspec-flake classify --from-store --store .rspec_flake_store --json
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Export features, rank files, train weights, and evaluate results:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
rspec-flake features spec/models/user_spec.rb --json
|
|
223
|
+
rspec-flake predict spec/models/user_spec.rb spec/system/search_spec.rb --json
|
|
224
|
+
rspec-flake train tmp/flake_features.jsonl --out tmp/flake_weights.json --json
|
|
225
|
+
rspec-flake evaluate --predictions tmp/predictions.json --ground-truth tmp/ground_truth.json --json
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Run sensitivity checks or summarize the store:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
rspec-flake sensitivity 'spec/models/user_spec.rb[1:2]' --factor network --json
|
|
232
|
+
rspec-flake report --store .rspec_flake_store --json
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Labels
|
|
236
|
+
|
|
237
|
+
Labels are multi-value and confidence-scored. A single failure can receive more
|
|
238
|
+
than one label.
|
|
239
|
+
|
|
240
|
+
Common labels include:
|
|
241
|
+
|
|
242
|
+
- `test_order_dependency`
|
|
243
|
+
- `async_wait`
|
|
244
|
+
- `concurrency`
|
|
245
|
+
- `resource_leak`
|
|
246
|
+
- `network`
|
|
247
|
+
- `time`
|
|
248
|
+
- `io`
|
|
249
|
+
- `randomness`
|
|
250
|
+
- `floating_point`
|
|
251
|
+
- `unordered_collections`
|
|
252
|
+
- `infrastructure`
|
|
253
|
+
- `suspected_flaky_deflaker`
|
|
254
|
+
- `known_flaky`
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
After checking out the repo, run:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
bin/setup
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Run the test suite:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
bundle exec rake spec
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Validate RBS signatures:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
bundle exec rbs validate
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Build the gem:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
bundle exec rake build
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Open an interactive console:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
bin/console
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
To install this gem onto your local machine:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
bundle exec rake install
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Contributing
|
|
295
|
+
|
|
296
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
297
|
+
https://github.com/ydah/rspec-flake-classifier.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
The gem is available as open source under the terms of the MIT License.
|
data/Rakefile
ADDED
data/exe/rspec-flake
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context"
|
|
4
|
+
require_relative "result"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module FlakeClassifier
|
|
8
|
+
module Classify
|
|
9
|
+
class Classifier
|
|
10
|
+
RECOMMENDATIONS = {
|
|
11
|
+
"test_order_dependency" => "Isolate shared state and inspect polluter or cleaner examples.",
|
|
12
|
+
"async_wait" => "Replace sleeps with observable waits or Capybara matchers.",
|
|
13
|
+
"concurrency" => "Synchronize shared state and make thread lifecycle explicit.",
|
|
14
|
+
"resource_leak" => "Close leaked resources in example cleanup hooks.",
|
|
15
|
+
"network" => "Stub external calls with WebMock/VCR and fail on real sockets.",
|
|
16
|
+
"time" => "Freeze time and cover timezone or date-boundary cases explicitly.",
|
|
17
|
+
"io" => "Use isolated temp paths and avoid filesystem order assumptions.",
|
|
18
|
+
"randomness" => "Fix srand or dependency seeds and avoid random production data in assertions.",
|
|
19
|
+
"floating_point" => "Assert with tolerance instead of exact float equality.",
|
|
20
|
+
"unordered_collections" => "Use match_array/contain_exactly or compare sets.",
|
|
21
|
+
"infrastructure" => "Check CI host limits, disk, memory, and scheduler pressure.",
|
|
22
|
+
"suspected_flaky_deflaker" => "Review recent changes first; this failure did not cover changed lines.",
|
|
23
|
+
"known_flaky" => "Inspect the stored classification and linked commits."
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def classify(input = nil, **attributes)
|
|
27
|
+
context = build_context(input, attributes)
|
|
28
|
+
labels = detectors.each_with_object([]) do |detector, result|
|
|
29
|
+
detected = detector.call(context)
|
|
30
|
+
next unless detected
|
|
31
|
+
|
|
32
|
+
detected.is_a?(Array) ? result.concat(detected.compact) : result << detected
|
|
33
|
+
end
|
|
34
|
+
status = labels.empty? ? "unknown" : "flaky"
|
|
35
|
+
|
|
36
|
+
Result.new(status: status, labels: labels, evidence: labels.flat_map(&:evidence))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_context(input, attributes)
|
|
42
|
+
return input if input.is_a?(Context)
|
|
43
|
+
|
|
44
|
+
if input.respond_to?(:message)
|
|
45
|
+
attributes[:message] ||= input.message
|
|
46
|
+
attributes[:backtrace] ||= input.backtrace
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Context.new(**attributes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def detectors
|
|
53
|
+
[
|
|
54
|
+
method(:deflaker_suspect),
|
|
55
|
+
method(:order_dependency),
|
|
56
|
+
method(:sensitivity),
|
|
57
|
+
method(:async_wait),
|
|
58
|
+
method(:concurrency),
|
|
59
|
+
method(:resource_leak),
|
|
60
|
+
method(:network),
|
|
61
|
+
method(:time),
|
|
62
|
+
method(:io),
|
|
63
|
+
method(:randomness),
|
|
64
|
+
method(:floating_point),
|
|
65
|
+
method(:unordered_collections),
|
|
66
|
+
method(:infrastructure)
|
|
67
|
+
]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def order_dependency(context)
|
|
71
|
+
return unless context.order_dependent
|
|
72
|
+
|
|
73
|
+
label("test_order_dependency", 0.95, ["rerun protocol marked this example as order-dependent"])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def deflaker_suspect(context)
|
|
77
|
+
result = metadata_value(context, :deflaker)
|
|
78
|
+
return unless result && boolean_value(result, "suspected")
|
|
79
|
+
|
|
80
|
+
label("suspected_flaky_deflaker", 0.9, [result.fetch("reason", "covered code did not intersect changed lines")])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def sensitivity(context)
|
|
84
|
+
result = metadata_value(context, :sensitivity)
|
|
85
|
+
factors = result && (result["factors"] || result[:factors])
|
|
86
|
+
return unless factors
|
|
87
|
+
|
|
88
|
+
factors.filter_map do |factor, factor_result|
|
|
89
|
+
next unless boolean_value(factor_result, "sensitive")
|
|
90
|
+
|
|
91
|
+
category = sensitivity_category(factor)
|
|
92
|
+
label(category, 0.9, ["#{factor} sensitivity run changed the pass/fail outcome"])
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def async_wait(context)
|
|
97
|
+
evidence = []
|
|
98
|
+
evidence << "wait/sleep/capybara call appears in failure context" if context.text.match?(/sleep|wait|eventually|capybara|selenium/i)
|
|
99
|
+
evidence << "large runtime spread across reruns" if large_duration_spread?(context.durations)
|
|
100
|
+
return if evidence.empty?
|
|
101
|
+
|
|
102
|
+
label("async_wait", evidence.length == 2 ? 0.85 : 0.65, evidence)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def concurrency(context)
|
|
106
|
+
return unless context.text.match?(/thread|fiber|mutex|deadlock|race|concurrent|monitor/i)
|
|
107
|
+
|
|
108
|
+
label("concurrency", 0.8, ["threading or race-related signal found"])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resource_leak(context)
|
|
112
|
+
evidence = []
|
|
113
|
+
evidence << "resource probe reported leaked resources" unless context.resource_list.empty?
|
|
114
|
+
evidence << "failure context mentions leaked or exhausted resources" if context.text.match?(/leak|too many open files|connection pool|fd|descriptor/i)
|
|
115
|
+
return if evidence.empty?
|
|
116
|
+
|
|
117
|
+
label("resource_leak", evidence.length == 2 ? 0.9 : 0.75, evidence)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def network(context)
|
|
121
|
+
evidence = []
|
|
122
|
+
evidence << "socket probe recorded real network use" unless context.socket_list.empty?
|
|
123
|
+
evidence << "network exception or HTTP stack appears in failure context" if context.text.match?(/webmock|vcr|net::http|socket|dns|econn|timeout|timed out|connection refused/i)
|
|
124
|
+
return if evidence.empty?
|
|
125
|
+
|
|
126
|
+
label("network", evidence.length == 2 ? 0.9 : 0.8, evidence)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def time(context)
|
|
130
|
+
return unless context.text.match?(/timecop|active_support::testing::timehelpers|timezone|time zone|tz|date|time\.now|today|midnight/i)
|
|
131
|
+
|
|
132
|
+
label("time", 0.8, ["time or timezone dependency appears in failure context"])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def io(context)
|
|
136
|
+
evidence = []
|
|
137
|
+
evidence << "filesystem probe recorded file activity" unless context.file_list.empty?
|
|
138
|
+
evidence << "filesystem exception appears in failure context" if context.text.match?(/errno::e|no such file|permission denied|file|directory|tmpdir|tmp/i)
|
|
139
|
+
return if evidence.empty?
|
|
140
|
+
|
|
141
|
+
label("io", evidence.length == 2 ? 0.9 : 0.75, evidence)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def randomness(context)
|
|
145
|
+
return unless context.text.match?(/srand|securerandom|random|faker|seed|shuffle|sample/i)
|
|
146
|
+
|
|
147
|
+
label("randomness", 0.75, ["randomness or seed signal appears in failure context"])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def floating_point(context)
|
|
151
|
+
numbers = context.text.scan(/[-+]?\d+\.\d+(?:e[-+]?\d+)?/i).map(&:to_f)
|
|
152
|
+
return if numbers.length < 2
|
|
153
|
+
|
|
154
|
+
close_pair = numbers.combination(2).any? { |left, right| close_float?(left, right) }
|
|
155
|
+
return unless close_pair
|
|
156
|
+
|
|
157
|
+
label("floating_point", 0.85, ["near-equal floating point values appear in failure diff"])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def unordered_collections(context)
|
|
161
|
+
arrays = context.text.scan(/\[([^\[\]]+)\]/m).map { |(body)| tokenize_array(body) }.reject(&:empty?)
|
|
162
|
+
return if arrays.length < 2
|
|
163
|
+
|
|
164
|
+
matching_set = arrays.combination(2).any? do |left, right|
|
|
165
|
+
left != right && left.sort == right.sort
|
|
166
|
+
end
|
|
167
|
+
return unless matching_set
|
|
168
|
+
|
|
169
|
+
label("unordered_collections", 0.85, ["array-like values contain the same elements in different order"])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def infrastructure(context)
|
|
173
|
+
return unless context.text.match?(/out of memory|no space left|ci|buildkite|circleci|runner|killed|process terminated/i)
|
|
174
|
+
|
|
175
|
+
label("infrastructure", 0.55, ["failure context points to CI or host resource pressure"])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def label(category, confidence, evidence)
|
|
179
|
+
Label.new(
|
|
180
|
+
category: category,
|
|
181
|
+
confidence: confidence,
|
|
182
|
+
evidence: evidence,
|
|
183
|
+
action: RECOMMENDATIONS.fetch(category)
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def large_duration_spread?(durations)
|
|
188
|
+
return false if durations.length < 2
|
|
189
|
+
|
|
190
|
+
min, max = durations.minmax
|
|
191
|
+
min.positive? && (max / min) >= 2.0
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def close_float?(left, right)
|
|
195
|
+
delta = (left - right).abs
|
|
196
|
+
return false if delta.zero?
|
|
197
|
+
|
|
198
|
+
scale = [left.abs, right.abs, 1.0].max
|
|
199
|
+
delta <= scale * 1e-6
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def metadata_value(context, key)
|
|
203
|
+
metadata = context.metadata || {}
|
|
204
|
+
metadata[key] || metadata[key.to_s]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def boolean_value(hash, key)
|
|
208
|
+
return false unless hash.respond_to?(:fetch)
|
|
209
|
+
|
|
210
|
+
hash.fetch(key, hash.fetch(key.to_sym, false)) == true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def sensitivity_category(factor)
|
|
214
|
+
case factor.to_s
|
|
215
|
+
when "time" then "time"
|
|
216
|
+
when "randomness" then "randomness"
|
|
217
|
+
when "network" then "network"
|
|
218
|
+
else "infrastructure"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def tokenize_array(body)
|
|
223
|
+
body.split(",").map { |value| value.strip.gsub(/\A["']|["']\z/, "") }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
module Classify
|
|
6
|
+
Context = Struct.new(
|
|
7
|
+
:message,
|
|
8
|
+
:backtrace,
|
|
9
|
+
:source,
|
|
10
|
+
:duration_samples,
|
|
11
|
+
:order_dependent,
|
|
12
|
+
:single_run_passed,
|
|
13
|
+
:resources,
|
|
14
|
+
:files,
|
|
15
|
+
:sockets,
|
|
16
|
+
:metadata,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
) do
|
|
19
|
+
def text
|
|
20
|
+
[message, *Array(backtrace), source].compact.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def durations
|
|
24
|
+
Array(duration_samples).map(&:to_f)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resource_list
|
|
28
|
+
Array(resources)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def file_list
|
|
32
|
+
Array(files)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def socket_list
|
|
36
|
+
Array(sockets)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
module Classify
|
|
6
|
+
Label = Struct.new(:category, :confidence, :evidence, :action, keyword_init: true) do
|
|
7
|
+
def to_h
|
|
8
|
+
{
|
|
9
|
+
"category" => category,
|
|
10
|
+
"confidence" => confidence,
|
|
11
|
+
"evidence" => evidence,
|
|
12
|
+
"action" => action
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Result
|
|
18
|
+
attr_reader :status, :labels, :evidence
|
|
19
|
+
|
|
20
|
+
def initialize(status:, labels:, evidence: [])
|
|
21
|
+
@status = status.to_s
|
|
22
|
+
@labels = labels.sort_by { |label| -label.confidence.to_f }
|
|
23
|
+
@evidence = evidence
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def primary_label
|
|
27
|
+
labels.first
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flaky?
|
|
31
|
+
status == "flaky" || !labels.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
{
|
|
36
|
+
"status" => status,
|
|
37
|
+
"labels" => labels.map(&:to_h),
|
|
38
|
+
"evidence" => evidence
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|