deflake 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 +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/exe/deflake +15 -0
- data/lib/deflake/adapters/minitest.rb +81 -0
- data/lib/deflake/adapters/rspec.rb +107 -0
- data/lib/deflake/adapters.rb +28 -0
- data/lib/deflake/bisect.rb +114 -0
- data/lib/deflake/child.rb +63 -0
- data/lib/deflake/cli.rb +137 -0
- data/lib/deflake/culprits.rb +18 -0
- data/lib/deflake/diagnosis.rb +30 -0
- data/lib/deflake/execution.rb +173 -0
- data/lib/deflake/investigation.rb +112 -0
- data/lib/deflake/json_report.rb +41 -0
- data/lib/deflake/leaks/cache.rb +29 -0
- data/lib/deflake/leaks/clock.rb +60 -0
- data/lib/deflake/leaks/current.rb +87 -0
- data/lib/deflake/leaks/env.rb +24 -0
- data/lib/deflake/leaks/globals.rb +157 -0
- data/lib/deflake/leaks/jobs.rb +26 -0
- data/lib/deflake/leaks/rows.rb +171 -0
- data/lib/deflake/leaks.rb +66 -0
- data/lib/deflake/minitest.rb +38 -0
- data/lib/deflake/minitest_reporter.rb +100 -0
- data/lib/deflake/minitest_tests.rb +161 -0
- data/lib/deflake/outcome.rb +19 -0
- data/lib/deflake/result_file.rb +89 -0
- data/lib/deflake/rspec.rb +90 -0
- data/lib/deflake/rspec_formatter.rb +91 -0
- data/lib/deflake/run.rb +11 -0
- data/lib/deflake/runner.rb +143 -0
- data/lib/deflake/summary.rb +55 -0
- data/lib/deflake/tail.rb +67 -0
- data/lib/deflake/text_report.rb +110 -0
- data/lib/deflake/version.rb +5 -0
- data/lib/deflake.rb +26 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bb6b0172e3743811b5c1d8cc3fa64bb8c4970c490615c3faef9e7b21156cdc3d
|
|
4
|
+
data.tar.gz: 992063be61424751f86dcdf6ad37e7acf895e10d2365785189a8bd691656d7d0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3aece386132f23ef51f9c2fd828dbc7c123d03103affae67836252fce76f173f310bc30f794188eab4430936344865b9ec2542a322c9ed2c52b6cdcc8a5b9ef5
|
|
7
|
+
data.tar.gz: 42363d3e5b5a486acc2a230e7c499c4f0ff4bc729fda1654c9e5db44361180dd93da0afbfa590a31e85d008a086f126184704787d2ef164855ca4cfe34d6e6ed
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-11-08
|
|
6
|
+
|
|
7
|
+
First public release.
|
|
8
|
+
|
|
9
|
+
- `deflake TEST_PATH[:LINE]` runs a Minitest or RSpec file many times, each in a fresh process with a new seed, and reports how often every test failed, with errors grouped by message and the seeds that reproduce them.
|
|
10
|
+
- Every failing test is run again on its own. A test that passes alone is reported as order-dependent, and deflake finds the tests that break it with its own bisect, for Minitest 5 and 6 and for RSpec. A failure must repeat to count, so a test that fails at random is reported as such, without culprits.
|
|
11
|
+
- Errors outside of any test, such as in an RSpec `after(:context)` hook, are reported too, and so are leaks of class-level hooks.
|
|
12
|
+
- Leak detection: every test is checked for global state it leaves behind, namely ENV, the clock (`travel_to` or `freeze_time` without `travel_back`), global variables, `Rails.cache`, `ActiveSupport::CurrentAttributes`, database rows and the ActiveJob test queue. Projects can add their own detectors.
|
|
13
|
+
- `--jobs N` runs test processes in parallel, each with its own `TEST_ENV_NUMBER`.
|
|
14
|
+
- `--timeout SECONDS` stops a test process that runs longer, for tests that hang.
|
|
15
|
+
- `--format json` for CI and other tools.
|
|
16
|
+
- Exit code 0 when every test passed and nothing leaked, 1 otherwise, 2 on usage errors, for a file without tests, or when the tests could not run. Ctrl-C stops the test processes and ends deflake as interrupted.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Роман Беляев
|
|
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,140 @@
|
|
|
1
|
+
# deflake
|
|
2
|
+
|
|
3
|
+
Find flaky tests in Minitest and RSpec and explain why they fail.
|
|
4
|
+
|
|
5
|
+
A flaky test fails once in a while, someone adds a retry, and the cause stays.
|
|
6
|
+
deflake runs a suspicious test many times and answers three questions:
|
|
7
|
+
|
|
8
|
+
1. **How often does it fail**, and with which errors and seeds?
|
|
9
|
+
2. **Does it fail on its own**, or only after some other test? If the latter, which one?
|
|
10
|
+
3. **What global state does a test leave behind**: ENV, the clock, globals, `Rails.cache`, `Current` attributes, database rows, enqueued jobs?
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add it to the test group of your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
group :test do
|
|
18
|
+
gem "deflake", require: false
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
deflake needs Ruby 3.3 or newer and works with Minitest 5.11 or newer (6 included) and RSpec 3.3 or newer. It has no dependencies of its own.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Run deflake from the root of your project:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle exec deflake test/models/user_test.rb --runs 50
|
|
30
|
+
bundle exec deflake spec/models/user_spec.rb:42 --runs 50 --jobs 4
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
OrderTest#test_pollutes: failed 0 of 10 runs (0%)
|
|
35
|
+
leaks: $polluted was changed (10 of 10 runs)
|
|
36
|
+
OrderTest#test_victim: failed 6 of 10 runs (60%)
|
|
37
|
+
verdict: fails only after other tests (alone: failed 0 of 10 runs (0%))
|
|
38
|
+
culprit: OrderTest#test_pollutes
|
|
39
|
+
reproduce: PARALLEL_WORKERS=1 MT_CPU=1 ruby -Ilib -Itest test/models/order_test.rb --seed 16166
|
|
40
|
+
seeds 16166, 35999, 58287, 35391, 30777, 1131:
|
|
41
|
+
polluted by another test
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
What happens:
|
|
45
|
+
|
|
46
|
+
1. **The whole file runs `--runs` times** (10 by default), every time in a fresh `ruby` process with a new random seed. Running the whole file, not just one test, is what makes order-dependent failures show up. A test that takes its process down (`exit`, a crash, a signal) counts as failed in that run.
|
|
47
|
+
2. **Every test that failed runs again on its own**, as many times. The verdict is *fails on its own* if it still fails, which points at time, randomness or a race inside the test, or *fails only after other tests* if it does not, or if it skips itself every time it runs alone. When it cannot run alone at all, as with errors outside of tests or a file that breaks before any test whenever one test runs on its own, the verdict is *failed, could not run it alone*. The errors of the runs alone are shown too when the runs of the file did not have them.
|
|
48
|
+
3. **Order-dependent failures are bisected** with one of the failing seeds, down to a smallest set of tests that break the victim; when two tests break it independently, one set is named. deflake has the test framework run just those tests in the recorded order, so hooks around a whole class still run, and watches only the victim, so other failing tests do not get in the way. A failure counts only when it happens twice in a row, and the set found must break the victim again at the end, so a test that fails at random gets no culprits: when the failure does not happen again with the same seed and order, the verdict is *fails at random, not after particular tests*, with the command that runs that seed. Tests in a class without a name cannot be put in an order, so when the file has some and the failure does not happen again without them, the verdict stays *fails only after other tests*, and deflake says it could not look among them. When the victim fails with that seed with no test before it, the verdict is *fails on its own*, with the command that shows it. Up to three failing seeds are tried, the first seed of every error first. A test that fails on its own is bisected too when the runs of the file have an error it never had alone. A culprit that two other tests of its group replace, because the state comes from a hook of the group such as `before(:context)` or `before_all`, is named as such. A victim stopped by `--timeout` does not count as failing in a bisect, as fewer tests before it would not reach the limit. This works on Minitest 5 and 6 alike, `Minitest::Spec` included, and on RSpec, which keeps the relative order of any subset of examples for a seed.
|
|
49
|
+
4. **Every test is checked for leaks**, tests that skip themselves included: a snapshot of global state before the test, setup included, and after it, teardown, transaction rollback and whatever Rails does around a test (such as the executor clearing `Current`) included. Whatever changed is reported. So is what a whole Minitest class or top-level RSpec group leaves behind beyond the leaks of its tests, such as the state a `before_all` or `before(:context)` hook sets up and never puts back: it shows up under *errors outside of tests*.
|
|
50
|
+
|
|
51
|
+
A process that fails while every test in it passed is reported as *errors outside of tests*, with the end of its output. An error RSpec reports outside of examples, such as in an `after(:context)` hook, is reported with its class and message, even when an example failed in the same run. So is a run that breaks before its first test, such as a `before(:suite)` hook that fails now and then: the other runs still count. A failure deflake cannot run again by name, such as a class-level hook (minitest-hooks' `before_all`) that reports its error as a result, shows up there too. Only when no run gets to a test does deflake give up with exit code 2, and say how the test process ended, for example that it timed out.
|
|
52
|
+
|
|
53
|
+
With `TEST_PATH:LINE` deflake still runs the whole file, but reports only the tests on that line, and errors outside of tests, which may be what stopped them. In Minitest that is the test declared closest above the line, whatever its style: `def test_...`, Rails' `test "..."`, `it "..."` or a macro of the project that defines a test; a line in a test of a class without a name is refused, as such a test cannot run alone. In RSpec it is what `rspec TEST_PATH:LINE` runs: the example on that line, or every example of a `describe` or `context` on that line, found with a dry run that runs no example and no hook. A test on the line that never started in any run, for example because an earlier test always takes the process down or a filter of the file leaves it out, makes deflake say so and exit with 1; the JSON report lists it with the verdict `not_run`.
|
|
54
|
+
|
|
55
|
+
Files ending in `_spec.rb` go to RSpec, unless they mention Minitest (a `minitest_helper` counts, and so do expectations such as `_(x).must_equal`) and neither RSpec nor `rails_helper`: those are `Minitest::Spec` files.
|
|
56
|
+
|
|
57
|
+
A Minitest test is known by its class and method, `UserTest#test_name`. When two classes have the same name, as two `describe` blocks with the same description do, the second one's tests are `Name (2)#test_...`. A failing test in a class without a name, made with `Class.new` and never given one, cannot be run again by name: its failure shows up under *errors outside of tests*. A test a file brings in from another one with `require_relative` belongs to that other file, not to the line of the `require_relative`.
|
|
58
|
+
|
|
59
|
+
### Options
|
|
60
|
+
|
|
61
|
+
| Option | Default | |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `-n`, `--runs N` | 10 | How many times to run the file |
|
|
64
|
+
| `-j`, `--jobs N` | 1 | How many test processes to run at once |
|
|
65
|
+
| `-t`, `--timeout SECONDS` | none | Stop a test process that runs longer, in whole seconds; the test it was running counts as failed |
|
|
66
|
+
| `-f`, `--format FORMAT` | `text` | `text` or `json` |
|
|
67
|
+
|
|
68
|
+
Exit code: 0 when every test passed and nothing leaked (or every test skipped itself), 1 otherwise, 2 on usage errors (a missing or extra `TEST_PATH`, `--runs` or `--jobs` below 1), when the file has no tests or a filter of the project leaves them all out, or when the tests could not run (deflake then shows their output). When interrupted with Ctrl-C, deflake stops its test processes and ends as an interrupted program does, so a shell loop around it stops too.
|
|
69
|
+
|
|
70
|
+
### Parallel runs and databases
|
|
71
|
+
|
|
72
|
+
With `--jobs` several processes run at once. Each gets `TEST_ENV_NUMBER` set to `""`, `"2"`, `"3"`... like [parallel_tests](https://github.com/grosser/parallel_tests), so a Rails app can give each its own database:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
# config/database.yml
|
|
76
|
+
test:
|
|
77
|
+
database: myapp_test<%= ENV["TEST_ENV_NUMBER"] %>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every test process runs in a session of its own, without a terminal, as in CI: its input is empty and its output is not a TTY, so a test that skips itself unless `$stdin.tty?` always skips under deflake. In RSpec, deflake runs every example even when the project asks for `fail_fast`, and leaves out the profile; options in `SPEC_OPTS` that set the seed, the order, the formatters or `--fail-fast` are left out too, as deflake sets them itself.
|
|
81
|
+
|
|
82
|
+
deflake sets `PARALLEL_WORKERS=1` and `MT_CPU=1` for every test process, so neither Rails' `parallelize` nor Minitest's `parallelize_me!` runs tests at once: the order a seed gives stays the same and the leaks of one test do not show up in another. To rerun a seed by hand, set them too, as the `reproduce:` command does; without `PARALLEL_WORKERS=1` Rails gives the same seed a different order.
|
|
83
|
+
|
|
84
|
+
### Leak detectors
|
|
85
|
+
|
|
86
|
+
| Detector | Reports |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| ENV | Variables set, changed or removed |
|
|
89
|
+
| Clock | `Time.now` frozen or moved away from the real clock: `freeze_time`, `travel_to` or Timecop left on, Timecop's `mock_process_clock` included. Where the clock ends up counts, so a test that brings back a clock another test moved leaves nothing behind |
|
|
90
|
+
| Globals | Global variables assigned or changed in place (not Ruby's own, such as `$CHILD_STATUS`). The whole content of what they hold is compared, as `Marshal` dumps it. An object `Marshal` cannot dump, such as a client holding a `Mutex` or a connection, counts by identity, so what it sets up inside on first use is not a leak; arrays, hashes, structs, sets and `StringIO` are still compared part by part, and queues by their size and whether they are closed. The order of a hash or a set does not matter. A value of any kind works, even a mock without Kernel's methods. `Marshal` dumps every global twice per test, so a global holding a lot of data makes every test slower |
|
|
91
|
+
| Cache | New keys in `Rails.cache`, when it is a `MemoryStore` |
|
|
92
|
+
| Current | `ActiveSupport::CurrentAttributes` left set; attributes with a proc default are not compared. A default changed in place, such as a list inside it, is reported too |
|
|
93
|
+
| Rows | Rows created or deleted outside a rolled back transaction, in every database connected before the test; fixtures loaded during a test are not counted, nor fixtures a test without a transaction deletes again. The counts bypass ActiveRecord's query cache |
|
|
94
|
+
| Jobs | Jobs left in the ActiveJob test queue. `ActiveJob::TestHelper` empties the queue before each test that includes it, so with every test including it these reports are noise: remove the detector as shown below |
|
|
95
|
+
|
|
96
|
+
Detectors that do not apply, for example Rows in an app without ActiveRecord, stay quiet. A detector is any object with two methods: `snapshot` returns the current state, or `nil` when it does not apply, and `diff(before, after)` returns one message per leak. Add your own in `test_helper.rb` or `spec_helper.rb`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
if ENV["DEFLAKE_OUTPUT"]
|
|
100
|
+
require "deflake/leaks"
|
|
101
|
+
|
|
102
|
+
class FeatureFlagLeaks
|
|
103
|
+
def snapshot = FeatureFlags.enabled.sort
|
|
104
|
+
def diff(before, after) = (after - before).map { |flag| "feature flag #{flag} left on" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Deflake::Leaks.detectors << FeatureFlagLeaks.new
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`DEFLAKE_OUTPUT` is set only in the processes deflake starts, so your regular test runs are not affected. The Rows detector counts rows in every table before and after each test; if that is too slow for a big schema, remove it the same way: `Deflake::Leaks.detectors.reject! { |detector| detector.is_a?(Deflake::Leaks::Rows) }`. The same goes for `Deflake::Leaks::Jobs`.
|
|
112
|
+
|
|
113
|
+
### JSON report
|
|
114
|
+
|
|
115
|
+
`--format json` prints one object per test that ran or leaked (a test that skipped itself in every run and leaked nothing is left out, as in the text report) with `name`, `id`, `verdict` (`stable`, `failed`, `fails_on_its_own`, `order_dependent`, `random`, or `not_run` for a test on the line that never started; `failed` means it could not run alone), `runs` and `isolated_runs` counts (`isolated_runs` is `null` when the test never failed or could not run alone, as errors outside of tests cannot) (`total`, `failures`, `failure_rate` and `skipped`: `total` leaves out the runs where the test skipped itself), `errors` with their seeds, `leaks` (with `runs`, counting the runs where the test skipped itself too) and `culprits` (`ids`, `names`, `command`, `alone` when the test fails with no test before it, `hidden` when tests in a class without a name may be to blame, and `group_wide`). The format may change until 1.0.
|
|
116
|
+
|
|
117
|
+
## Limitations
|
|
118
|
+
|
|
119
|
+
- deflake looks at one file at a time. A test broken by a test in another file shows up as failing only when the two files run together, which deflake does not do yet.
|
|
120
|
+
- Minitest files run as `ruby -Ilib -Itest FILE` (`lib` when there is one, and `-Ispec` too when there is a `spec` directory), RSpec files with RSpec's own defaults. A project that needs a different command is not supported yet.
|
|
121
|
+
- A Minitest error outside of tests is seen only in a run where every test passed: a failing test also makes the process fail.
|
|
122
|
+
- Leak detection sees what changed in the test process. It cannot see changes made by other processes, such as a browser in a system test.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bin/setup # install dependencies
|
|
128
|
+
bundle exec rake # run tests and RuboCop
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
To test against a specific Minitest major version:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
MINITEST_VERSION="~> 6.0" bundle update minitest
|
|
135
|
+
MINITEST_VERSION="~> 6.0" bundle exec rake test
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT, see [LICENSE.txt](LICENSE.txt).
|
data/exe/deflake
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "deflake"
|
|
5
|
+
|
|
6
|
+
code = Deflake::CLI.new(ARGV).run
|
|
7
|
+
# After Ctrl-C, end the way an interrupted program does, so a shell loop around deflake stops too
|
|
8
|
+
if code == 130
|
|
9
|
+
# SYSTEM_DEFAULT: Ruby's own handler would raise Interrupt and print its backtrace
|
|
10
|
+
trap("INT", "SYSTEM_DEFAULT")
|
|
11
|
+
Process.kill("INT", Process.pid)
|
|
12
|
+
# In case the signal arrives a moment later
|
|
13
|
+
sleep 1
|
|
14
|
+
end
|
|
15
|
+
exit code
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Deflake
|
|
7
|
+
module Adapters
|
|
8
|
+
# Runs a Minitest file, optionally only some of its tests, by ids such as "Class#test_name"
|
|
9
|
+
module Minitest
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Which tests to run goes through env. lib and test are on the load path as with rake test,
|
|
13
|
+
# and spec too when there is one, for Minitest::Spec files that require spec_helper
|
|
14
|
+
def command(path, seed:, **)
|
|
15
|
+
[RbConfig.ruby, "-I", LIB, *load_path.flat_map { |dir| ["-I", dir] }, "-r", "deflake/minitest", path,
|
|
16
|
+
"--seed", seed.to_s]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load_path
|
|
20
|
+
%w[lib test spec].select { |dir| dir == "test" || File.directory?(dir) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The ids go through the environment, not --name: a regexp on the command line follows
|
|
24
|
+
# the locale, and under LANG=C it never matches a name that is not ASCII
|
|
25
|
+
def env(only)
|
|
26
|
+
only ? { ResultFile::ONLY_KEY => ResultFile.pack_ids(only) } : {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Matches the tests defined closest above this line. Minitest itself says which tests there
|
|
30
|
+
# are, and where in this file each one is declared, so Rails' test "...", Minitest::Spec's
|
|
31
|
+
# it "..." and tests a macro defines work too. Returns their ids
|
|
32
|
+
def focus(path, line)
|
|
33
|
+
above = definitions(path).select { |test| test[:line] <= line }
|
|
34
|
+
nearest = above.map { |test| test[:line] }.max
|
|
35
|
+
raise Error, "no test found at #{path}:#{line}" unless nearest
|
|
36
|
+
|
|
37
|
+
named = above.filter_map { |test| test[:id] if test[:line] == nearest }
|
|
38
|
+
return named if named.any?
|
|
39
|
+
|
|
40
|
+
raise Error, "the test at #{path}:#{line} is in a class without a name, which deflake cannot run alone; " \
|
|
41
|
+
"run the whole file instead"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Tests declared in this file: { id:, line: }, with a nil id for a test in a class without a name
|
|
45
|
+
def definitions(path)
|
|
46
|
+
here = File.expand_path(path).b
|
|
47
|
+
env = { ResultFile::LIST_KEY => ResultFile.clean(File.expand_path(path)) }
|
|
48
|
+
rows = Execution.retrying { Execution.call(self, path, seed: 0, env: env) }
|
|
49
|
+
rows.select { |row| row["file"] && File.expand_path(row["file"]).b == here }
|
|
50
|
+
.map { |row| { id: row["listed"], line: row["line"] } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The order goes through a file: an environment variable cannot hold a big file's test ids
|
|
54
|
+
def run_in_order(path, ids, seed:)
|
|
55
|
+
Dir.mktmpdir do |dir|
|
|
56
|
+
order = File.join(dir, "order")
|
|
57
|
+
File.write(order, ResultFile.pack_ids(ids))
|
|
58
|
+
Execution.call(self, path, seed: seed, env: { ResultFile::ORDER_KEY => order })
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def bisect(path, victim, seed:)
|
|
63
|
+
bisect = Bisect.new(path, seed: seed, adapter: self)
|
|
64
|
+
ids = bisect.call(victim)
|
|
65
|
+
Culprits.new(ids: ids, command: reproduce(path, seed), alone: bisect.alone?, hidden: bisect.hidden?,
|
|
66
|
+
group_wide: bisect.group_wide)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The class: its hooks, such as minitest-hooks' before_all, run with any of its tests
|
|
70
|
+
def group(id)
|
|
71
|
+
id.split("#", 2).first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# With the variables Execution sets: without them Rails' parallelize gives a seed another order
|
|
75
|
+
def reproduce(path, seed)
|
|
76
|
+
variables = Execution::SERIAL.map { |name, value| "#{name}=#{value}" }.join(" ")
|
|
77
|
+
"#{variables} ruby #{load_path.map { |dir| "-I#{dir}" }.join(" ")} #{Shellwords.escape(path)} --seed #{seed}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Deflake
|
|
7
|
+
module Adapters
|
|
8
|
+
# Runs an RSpec file, optionally only some examples (by their ids, "./spec/foo_spec.rb[1:2]")
|
|
9
|
+
module RSpec
|
|
10
|
+
# Like the rspec executable: invoke turns off rspec/autorun, which would run everything twice
|
|
11
|
+
RUN = "RSpec::Core::Runner.invoke"
|
|
12
|
+
# Options in SPEC_OPTS that would take over what deflake sets: the seed and order of a run,
|
|
13
|
+
# where the output goes and when the run stops. They win over the command line
|
|
14
|
+
OWN = %w[--seed --order --rand --random --format -f --out -o --fail-fast --no-fail-fast --bisect --profile -p
|
|
15
|
+
--no-profile --dry-run].freeze
|
|
16
|
+
# Those of them that take a separate value
|
|
17
|
+
VALUED = %w[--seed --order --format -f --out -o].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# only: an example id, a "path:line", or a list of example ids. The profile would fill the
|
|
22
|
+
# end of the output, where errors outside of examples are looked for
|
|
23
|
+
def command(path, seed:, only: nil)
|
|
24
|
+
# deflake/rspec adds deflake's own formatter
|
|
25
|
+
[*rspec(only || path, seed), "--format", "progress", "--no-profile"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def env(_only)
|
|
29
|
+
options = ENV.fetch("SPEC_OPTS", nil)
|
|
30
|
+
options ? { "SPEC_OPTS" => Shellwords.join(project_options(Shellwords.split(options))) } : {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# SPEC_OPTS without the options deflake sets itself
|
|
34
|
+
def project_options(words)
|
|
35
|
+
values = 0
|
|
36
|
+
words.reject do |word|
|
|
37
|
+
next (values -= 1) >= 0 if values.positive?
|
|
38
|
+
|
|
39
|
+
# "--seed 5" takes the next word too, "--seed=5" does not
|
|
40
|
+
values = VALUED.include?(word) ? 1 : 0
|
|
41
|
+
OWN.include?(word.split("=", 2).first)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Lets RSpec itself say which examples are at this line, as rspec path:line does: on the line
|
|
46
|
+
# of a group, all its examples. A dry run lists them without running any example or hook,
|
|
47
|
+
# so nothing a run does, such as --fail-fast or a hook that exits, can hide some of them.
|
|
48
|
+
# Returns their ids
|
|
49
|
+
def focus(path, line)
|
|
50
|
+
ids = Execution.retrying { examples_at(path, line) }
|
|
51
|
+
raise Error, "no test found at #{path}:#{line}" if ids.empty?
|
|
52
|
+
|
|
53
|
+
ids
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def examples_at(path, line)
|
|
57
|
+
Execution.call(self, path, seed: 0, only: ["--dry-run", "#{path}:#{line}"]).map { |row| row["id"] }.uniq
|
|
58
|
+
rescue Execution::NoResults => e
|
|
59
|
+
# A project with fail_if_no_examples fails a run that found none
|
|
60
|
+
raise unless e.success? || e.output.match?(/^0 examples, 0 failures/)
|
|
61
|
+
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# RSpec orders examples by a hash of the seed and each id, so a subset of examples runs in the
|
|
66
|
+
# same relative order as in the whole file. The ids go through a file: a command line cannot
|
|
67
|
+
# hold the ids of a file with thousands of examples
|
|
68
|
+
def run_in_order(path, ids, seed:)
|
|
69
|
+
Dir.mktmpdir do |dir|
|
|
70
|
+
order = File.join(dir, "order")
|
|
71
|
+
File.write(order, ResultFile.pack_ids(ids))
|
|
72
|
+
Execution.call(self, path, seed: seed, env: { ResultFile::ORDER_KEY => order })
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The same bisect as for Minitest, which checks only this victim. rspec --bisect
|
|
77
|
+
# keeps every example that fails with the seed, and hangs when one exits the process
|
|
78
|
+
def bisect(path, victim, seed:)
|
|
79
|
+
bisect = Bisect.new(path, seed: seed, adapter: self)
|
|
80
|
+
ids = bisect.call(victim)
|
|
81
|
+
targets = if ids.any? then compact(ids + [victim])
|
|
82
|
+
elsif bisect.alone? then [victim]
|
|
83
|
+
else [path]
|
|
84
|
+
end
|
|
85
|
+
Culprits.new(ids: ids, command: "rspec #{Shellwords.join(targets)} --seed #{seed}",
|
|
86
|
+
alone: bisect.alone?, hidden: bisect.hidden?, group_wide: bisect.group_wide)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The group an example is in: "./a_spec.rb[1:2:3]" => "./a_spec.rb[1:2]"
|
|
90
|
+
def group(id)
|
|
91
|
+
file, scope = id.match(/\A(.+)\[([^\]]+):\d+\]\z/)&.captures
|
|
92
|
+
"#{file}[#{scope}]" if file
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ["./a_spec.rb[1:1]", "./a_spec.rb[1:3]"] => ["./a_spec.rb[1:1,1:3]"], as rspec --bisect prints them
|
|
96
|
+
def compact(ids)
|
|
97
|
+
ids.group_by { |id| id[/\A(.+)\[[^\]]+\]\z/, 1] }.flat_map do |file, group|
|
|
98
|
+
file ? "#{file}[#{group.map { |id| id[/\[([^\]]+)\]\z/, 1] }.join(",")}]" : group
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def rspec(target, seed)
|
|
103
|
+
[RbConfig.ruby, "-I", LIB, "-r", "deflake/rspec", "-e", RUN, "--", *Array(target), "--seed", seed.to_s]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "adapters/minitest"
|
|
5
|
+
require_relative "adapters/rspec"
|
|
6
|
+
|
|
7
|
+
module Deflake
|
|
8
|
+
# Each adapter builds the command that runs a test file with deflake's recorder loaded
|
|
9
|
+
module Adapters
|
|
10
|
+
# Minitest itself, a helper named after it such as minitest_helper, or its expectations: _(x).must_equal
|
|
11
|
+
MINITEST = /minitest|mini_test|\b_\(.*\)\.(?:must|wont)_/i
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Minitest::Spec files are often named *_spec.rb too, so a spec file is sent to
|
|
16
|
+
# Minitest when it mentions Minitest and neither RSpec nor rails_helper. A spec_helper
|
|
17
|
+
# says nothing: Minitest::Spec projects use one as well
|
|
18
|
+
def for(path)
|
|
19
|
+
return Minitest unless path.to_s.end_with?("_spec.rb")
|
|
20
|
+
|
|
21
|
+
# Read as UTF-8 whatever the locale, as Ruby reads the test file itself
|
|
22
|
+
source = File.read(path, mode: "r:UTF-8").scrub
|
|
23
|
+
source.match?(MINITEST) && !source.match?(/\bRSpec\b|rails_helper/) ? Minitest : RSpec
|
|
24
|
+
rescue SystemCallError
|
|
25
|
+
RSpec
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# Finds the smallest set of tests that make a test fail when they run before it.
|
|
5
|
+
# Works with any adapter that can run a list of tests in a given order (run_in_order), and
|
|
6
|
+
# tell the group of a test (group) if it can.
|
|
7
|
+
# A failure counts only when it repeats: a test that fails at random would otherwise
|
|
8
|
+
# get culprits that have nothing to do with it
|
|
9
|
+
class Bisect
|
|
10
|
+
# How many runs in a row a failure must happen in to count
|
|
11
|
+
CONFIRM = 2
|
|
12
|
+
# How many other tests of a group must stand in for a culprit
|
|
13
|
+
STAND_INS = 2
|
|
14
|
+
|
|
15
|
+
def initialize(path, seed:, adapter:)
|
|
16
|
+
@path = path
|
|
17
|
+
@seed = seed
|
|
18
|
+
@adapter = adapter
|
|
19
|
+
@alone = false
|
|
20
|
+
@group_wide = []
|
|
21
|
+
@hidden = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether the failure did not happen again, and tests that cannot run by name, such as those
|
|
25
|
+
# in a class without a name, ran in the whole file: one of them may be the culprit
|
|
26
|
+
def hidden? = @hidden
|
|
27
|
+
|
|
28
|
+
# Whether the victim failed with this seed with no test before it
|
|
29
|
+
def alone? = @alone
|
|
30
|
+
|
|
31
|
+
# Culprits that other tests of their group stand in for, so their group's hook is to blame
|
|
32
|
+
attr_reader :group_wide
|
|
33
|
+
|
|
34
|
+
# Returns culprit ids, or an empty array if the failure does not reproduce or happens alone
|
|
35
|
+
def call(victim)
|
|
36
|
+
@order, unnamed = Execution.retrying { Execution.order(@adapter, @path, seed: @seed) }
|
|
37
|
+
before = @order.take_while { |id| id != victim }
|
|
38
|
+
return hidden(unnamed) unless fails?(before, victim, CONFIRM)
|
|
39
|
+
return alone if fails?([], victim, CONFIRM)
|
|
40
|
+
|
|
41
|
+
confirmed(minimize(before) { |tests| fails?(tests, victim) }, victim)
|
|
42
|
+
rescue Execution::NoResults
|
|
43
|
+
# The process broke before its first test every time, so the failure did not reproduce
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def alone
|
|
50
|
+
@alone = true
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hidden(unnamed)
|
|
55
|
+
@hidden = unnamed
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Smaller steps ran once each, so the end result must break the victim again
|
|
60
|
+
def confirmed(culprits, victim)
|
|
61
|
+
return [] unless culprits.any? && fails?(culprits, victim, CONFIRM)
|
|
62
|
+
|
|
63
|
+
@group_wide = group_wide_of(culprits, victim)
|
|
64
|
+
culprits
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only this victim counts: other tests may fail too, a skipped victim did not fail, and neither
|
|
68
|
+
# did one stopped by the timeout, which a shorter list of tests would not reach. A run that
|
|
69
|
+
# broke before its first test, as a flaky boot does now and then, is tried again
|
|
70
|
+
def fails?(tests, victim, times = 1)
|
|
71
|
+
times.times.all? do
|
|
72
|
+
rows = Execution.retrying { @adapter.run_in_order(@path, tests + [victim], seed: @seed) }
|
|
73
|
+
rows.any? { |row| row["id"] == victim && row["passed"] == false && !row["timed_out"] }
|
|
74
|
+
rescue Execution::NoResults
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Drops ever smaller chunks of candidates while the failure still happens
|
|
80
|
+
def minimize(candidates)
|
|
81
|
+
size = (candidates.size / 2.0).ceil
|
|
82
|
+
while size.positive?
|
|
83
|
+
candidates.each_slice(size).to_a.each do |chunk|
|
|
84
|
+
rest = candidates - chunk
|
|
85
|
+
candidates = rest if yield(rest)
|
|
86
|
+
end
|
|
87
|
+
size /= 2
|
|
88
|
+
end
|
|
89
|
+
candidates
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A culprit that other tests of its group replace with the same failure is not to blame
|
|
93
|
+
# itself: the group's hooks are, such as before(:context) or minitest-hooks' before_all.
|
|
94
|
+
# Two of them must, as one may break the victim on its own
|
|
95
|
+
def group_wide_of(culprits, victim)
|
|
96
|
+
culprits.select do |culprit|
|
|
97
|
+
stand_ins = stand_ins(culprit, culprits + [victim])
|
|
98
|
+
stand_ins.any? && stand_ins.all? do |stand_in|
|
|
99
|
+
fails?(in_order(culprits - [culprit] + [stand_in]), victim, CONFIRM)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Other tests of the culprit's group, none of these. An adapter that knows no groups has none
|
|
105
|
+
def stand_ins(culprit, taken)
|
|
106
|
+
group = @adapter.respond_to?(:group) && @adapter.group(culprit) or return []
|
|
107
|
+
@order.select { |id| !taken.include?(id) && @adapter.group(id) == group }.first(STAND_INS)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def in_order(ids)
|
|
111
|
+
ids.sort_by { |id| @order.index(id) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deflake
|
|
4
|
+
# Starts a test process and waits for it. The output goes to a file, not a pipe: a process the
|
|
5
|
+
# tests started in the background would keep a pipe open, and deflake would wait for it. The
|
|
6
|
+
# test process gets a session of its own, without a terminal: in deflake's process group a test
|
|
7
|
+
# that touches the terminal settings would be stopped, and a timeout or an interrupt stops
|
|
8
|
+
# whatever it started too. After an interrupt the killed process is reaped before deflake exits,
|
|
9
|
+
# not left to init
|
|
10
|
+
module Child
|
|
11
|
+
# The status of a process killed because it ran longer than the timeout
|
|
12
|
+
TimedOut = Struct.new(:status, :seconds) do
|
|
13
|
+
def success? = false
|
|
14
|
+
def signaled? = false
|
|
15
|
+
def exitstatus = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns the process status, or TimedOut. timeout: seconds, nil for no limit
|
|
21
|
+
def run(env, command, log, timeout)
|
|
22
|
+
pid = start(env, command, log)
|
|
23
|
+
waiter = Process.detach(pid)
|
|
24
|
+
return waiter.value if waiter.join(timeout)
|
|
25
|
+
|
|
26
|
+
kill(pid)
|
|
27
|
+
TimedOut.new(waiter.value, timeout)
|
|
28
|
+
ensure
|
|
29
|
+
if waiter&.alive?
|
|
30
|
+
kill(pid)
|
|
31
|
+
waiter.join(5)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start(env, command, log)
|
|
36
|
+
Process.fork do
|
|
37
|
+
Process.setsid
|
|
38
|
+
exec(env, *command, in: File::NULL, %i[out err] => log)
|
|
39
|
+
rescue SystemCallError => e
|
|
40
|
+
File.write(log, "deflake: #{e.message}\n")
|
|
41
|
+
exit!(127)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Before the new process starts its session, it has no group of its own yet
|
|
46
|
+
def kill(pid)
|
|
47
|
+
Process.kill("KILL", -pid)
|
|
48
|
+
rescue SystemCallError
|
|
49
|
+
begin
|
|
50
|
+
Process.kill("KILL", pid)
|
|
51
|
+
rescue SystemCallError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ending(status)
|
|
57
|
+
return "timed out after #{status.seconds} seconds" if status.is_a?(TimedOut)
|
|
58
|
+
return "was killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
|
|
59
|
+
|
|
60
|
+
"exited with status #{status.exitstatus}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|