padded_cell 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 +22 -0
- data/Cargo.lock +518 -0
- data/Cargo.toml +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/exe/padded_cell +6 -0
- data/ext/padded_cell_native/core/Cargo.toml +20 -0
- data/ext/padded_cell_native/core/README.md +50 -0
- data/ext/padded_cell_native/core/src/discover/tests.rs +107 -0
- data/ext/padded_cell_native/core/src/discover.rs +454 -0
- data/ext/padded_cell_native/core/src/hash/tests.rs +13 -0
- data/ext/padded_cell_native/core/src/hash.rs +13 -0
- data/ext/padded_cell_native/core/src/lib.rs +30 -0
- data/ext/padded_cell_native/core/src/plan/tests.rs +61 -0
- data/ext/padded_cell_native/core/src/plan.rs +109 -0
- data/ext/padded_cell_native/core/src/rng/tests.rs +17 -0
- data/ext/padded_cell_native/core/src/rng.rs +28 -0
- data/ext/padded_cell_native/extconf.rb +46 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +19 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +120 -0
- data/lib/padded_cell/active_support_hooks.rb +30 -0
- data/lib/padded_cell/cell.rb +87 -0
- data/lib/padded_cell/cli.rb +97 -0
- data/lib/padded_cell/discovery.rb +24 -0
- data/lib/padded_cell/hooks.rb +33 -0
- data/lib/padded_cell/loader.rb +82 -0
- data/lib/padded_cell/native.rb +23 -0
- data/lib/padded_cell/pool.rb +202 -0
- data/lib/padded_cell/railtie.rb +18 -0
- data/lib/padded_cell/reporters.rb +106 -0
- data/lib/padded_cell/results.rb +108 -0
- data/lib/padded_cell/runner.rb +131 -0
- data/lib/padded_cell/scheduler.rb +21 -0
- data/lib/padded_cell/selection.rb +78 -0
- data/lib/padded_cell/timings.rb +30 -0
- data/lib/padded_cell/version.rb +5 -0
- data/lib/padded_cell.rb +29 -0
- metadata +113 -0
data/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# padded_cell
|
|
2
|
+
|
|
3
|
+
Your tests aren't crazy. They just need their own room.
|
|
4
|
+
|
|
5
|
+
padded_cell is a drop-in runner for Minitest and Rails test suites. Nothing in
|
|
6
|
+
your tests changes; padded_cell replaces the thing that runs them:
|
|
7
|
+
|
|
8
|
+
- **every test in its own forked cell**: a test that segfaults, calls `exit`,
|
|
9
|
+
hangs, monkey patches `Time` or leaves a thread behind takes down its cell,
|
|
10
|
+
not the run, and not the next test (Rails suites with fixtures default to
|
|
11
|
+
sharing a worker instead, see [Isolation](#isolation));
|
|
12
|
+
- **workers fed whole classes**, slowest first from the timings of the
|
|
13
|
+
previous run, so per-class setup such as fixtures happens once; idle
|
|
14
|
+
workers split the remaining big classes;
|
|
15
|
+
- **static discovery** with Prism: `file:line`, `--list` and CI sharding work
|
|
16
|
+
without loading a single test file;
|
|
17
|
+
- Minitest's own reporters and plugins, plus JUnit XML and JSON lines.
|
|
18
|
+
|
|
19
|
+
Discovery and scheduling are Rust, in a native extension: Prism parses the
|
|
20
|
+
test files on every core, and a deterministic scheduler orders the run.
|
|
21
|
+
|
|
22
|
+
```console
|
|
23
|
+
$ bundle exec padded_cell
|
|
24
|
+
Run options: --seed 33512 --isolation test
|
|
25
|
+
|
|
26
|
+
# Running:
|
|
27
|
+
|
|
28
|
+
.....E.......................................
|
|
29
|
+
|
|
30
|
+
Finished in 2.083220s, 21.6013 runs/s, 233.2927 assertions/s.
|
|
31
|
+
|
|
32
|
+
1) Error:
|
|
33
|
+
PaymentTest#test_refund:
|
|
34
|
+
PaddedCell::CellCrashed: cell killed by SIGABRT
|
|
35
|
+
test/models/payment_test.rb:41:in 'PaymentTest#test_refund'
|
|
36
|
+
|
|
37
|
+
45 runs, 486 assertions, 0 failures, 1 errors, 0 skips
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
One test segfaulted a C extension (Ruby aborts on a segfault) and took its cell
|
|
41
|
+
down. The other 44 never noticed.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Requires CRuby 4.0 or newer and Minitest 6, on Linux, macOS or FreeBSD.
|
|
46
|
+
padded_cell is built on `fork`: Windows, JRuby and TruffleRuby are not
|
|
47
|
+
supported.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
group :test do
|
|
51
|
+
gem "padded_cell"
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Precompiled gems ship for Linux (x86_64 and aarch64; glibc 2.34+ such as
|
|
56
|
+
Debian 13, Ubuntu 26.04, RHEL 10, Fedora, Arch or Omarchy, plus musl for
|
|
57
|
+
Alpine), macOS 13+ (arm64, x86_64) and FreeBSD 15.1 (amd64): no Rust, no
|
|
58
|
+
compiler, nothing to build. Anything else (another CPU, an older glibc) gets
|
|
59
|
+
the source gem, which compiles the extension at install and needs Rust 1.99+.
|
|
60
|
+
padded_cell does not load without its extension.
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
$ padded_cell # test/**/*_test.rb, minus system, dummy and fixtures
|
|
66
|
+
$ padded_cell test/models # a directory
|
|
67
|
+
$ padded_cell test/models/user_test.rb:12 # the test spanning line 12
|
|
68
|
+
$ padded_cell user_test.rb:12-30:41 # tests overlapping 12..30, or spanning 41
|
|
69
|
+
$ padded_cell -n /refund/ -e test_slow # Minitest's --include / --exclude
|
|
70
|
+
$ padded_cell -j 8 --seed 1234 # 8 workers, reproducible order
|
|
71
|
+
$ padded_cell --timeout 30 # kill and fail anything running 30s
|
|
72
|
+
$ padded_cell --fail-fast
|
|
73
|
+
$ padded_cell --list # what would run, without loading anything
|
|
74
|
+
$ padded_cell --shard 2/4 # CI: the second quarter of the classes
|
|
75
|
+
$ padded_cell --junit tmp/junit.xml --json tmp/results.jsonl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Exit status: 0 when everything passed, 1 otherwise, 2 for usage or load
|
|
79
|
+
errors, 130 when interrupted.
|
|
80
|
+
|
|
81
|
+
### How a run works
|
|
82
|
+
|
|
83
|
+
1. **Select** files the way `rails test` does, `file:line` included.
|
|
84
|
+
2. **Discover** tests statically with Prism (`def test_*` and Rails'
|
|
85
|
+
`test "..."` in classes inheriting from a `...Test` or `...TestCase`).
|
|
86
|
+
3. **Load** the test files once, in the runner. Workers and cells are forks of
|
|
87
|
+
that process, so the app boots once, and copy-on-write shares its memory.
|
|
88
|
+
`minitest/autorun` is neutralized, so the suite does not run again at exit.
|
|
89
|
+
4. **Order**: keep each class's tests together, as Minitest and Rails do.
|
|
90
|
+
Shard by a stable hash of the class name, shuffle classes and tests with
|
|
91
|
+
the seed, then put the slowest classes, and within them the slowest
|
|
92
|
+
tests, first (`tmp/padded_cell/timings.tsv`).
|
|
93
|
+
5. **Run**: a worker takes a whole class; once no class is left to take, an
|
|
94
|
+
idle worker splits off the second half of another worker's remainder.
|
|
95
|
+
Each test sends back its `Minitest::Result`. A cell or worker that dies or
|
|
96
|
+
times out becomes an error result.
|
|
97
|
+
6. **Report** through Minitest's own reporters and plugins.
|
|
98
|
+
|
|
99
|
+
Loaded tests are the source of truth: inherited, `define_method` and other
|
|
100
|
+
dynamic tests run even though static discovery cannot see them.
|
|
101
|
+
|
|
102
|
+
### Isolation
|
|
103
|
+
|
|
104
|
+
`--isolation test` forks a cell per test. Forking a preloaded process is
|
|
105
|
+
cheap, and order-dependent tests stop depending on order.
|
|
106
|
+
|
|
107
|
+
`--isolation worker` runs tests directly in the long-lived workers, like
|
|
108
|
+
Rails' `parallelize`. A worker that crashes or times out is still reported
|
|
109
|
+
and replaced.
|
|
110
|
+
|
|
111
|
+
The default is `test`, except when Active Record fixtures are loaded: a fork
|
|
112
|
+
discards Active Record's connection pool, and every test would reload its
|
|
113
|
+
fixtures, so those suites default to `worker`. The `Run options:` line shows
|
|
114
|
+
which one a run uses; `--isolation` overrides it.
|
|
115
|
+
|
|
116
|
+
### Hooks
|
|
117
|
+
|
|
118
|
+
For anything that must not be shared across forks:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# test/test_helper.rb
|
|
122
|
+
PaddedCell.before_fork { Database.disconnect }
|
|
123
|
+
PaddedCell.after_fork { |worker| Database.connect("app_test_#{worker}") }
|
|
124
|
+
PaddedCell.cleanup { |worker| Database.drop("app_test_#{worker}") }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`before_fork` runs in the runner, `after_fork` and `cleanup` once per worker
|
|
128
|
+
(`0...jobs`). Cells run no hooks: they inherit their worker's state.
|
|
129
|
+
|
|
130
|
+
## Rails
|
|
131
|
+
|
|
132
|
+
Nothing to configure. ActiveSupport's parallelization hooks run around
|
|
133
|
+
padded_cell's workers as they would around Rails' own: `parallelize_setup`,
|
|
134
|
+
`parallelize_teardown`, `parallelize_before_fork`, and Active Record's
|
|
135
|
+
per-worker test databases (`app_test-0`, `app_test-1`...).
|
|
136
|
+
`ActiveSupport::TestCase.parallel_worker_id` is set. A
|
|
137
|
+
`parallelize(workers: ...)` call in `test_helper.rb` stays inert, since
|
|
138
|
+
padded_cell is the executor.
|
|
139
|
+
|
|
140
|
+
```console
|
|
141
|
+
$ bin/rails padded_cell ARGS="-j 8 test/models"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Rails suites with fixtures run with `--isolation worker` unless told
|
|
145
|
+
otherwise (see [Isolation](#isolation)).
|
|
146
|
+
|
|
147
|
+
Coverage tools that write their results from an `at_exit` hook (SimpleCov)
|
|
148
|
+
see only the runner, as with Rails' process parallelization.
|
|
149
|
+
|
|
150
|
+
## Library
|
|
151
|
+
|
|
152
|
+
The engine-backed parts load without Minitest:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
require "padded_cell"
|
|
156
|
+
|
|
157
|
+
PaddedCell::Discovery.call(Dir["test/**/*_test.rb"]).map { [it.id, it.lines] }
|
|
158
|
+
# => [["UserTest#test_valid", 10..12], ...]
|
|
159
|
+
|
|
160
|
+
PaddedCell::Scheduler.batches(class_names, durations: seconds, seed: 42, shard: [0, 4])
|
|
161
|
+
# => [[test indices of one class], ...] in run order
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Rust
|
|
165
|
+
|
|
166
|
+
The extension lives in `ext/padded_cell_native`: `core` does discovery and
|
|
167
|
+
scheduling in plain Rust (no Ruby types), `ffi` binds it to Ruby. Neither is
|
|
168
|
+
published as a crate.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
Needs Rust 1.99+ (`mise install` reads `mise.toml`).
|
|
173
|
+
|
|
174
|
+
```console
|
|
175
|
+
bundle install
|
|
176
|
+
rake test:all # cargo test, then the Ruby suite (compiles the extension first)
|
|
177
|
+
rake test # the Ruby suite only
|
|
178
|
+
rake dogfood # this suite, run by padded_cell
|
|
179
|
+
rake lint # dictator, cargo fmt --check, clippy
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
data/exe/padded_cell
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "padded_cell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Static Minitest discovery and test scheduling for the padded_cell gem"
|
|
5
|
+
publish = false
|
|
6
|
+
edition.workspace = true
|
|
7
|
+
rust-version.workspace = true
|
|
8
|
+
authors.workspace = true
|
|
9
|
+
license.workspace = true
|
|
10
|
+
repository.workspace = true
|
|
11
|
+
|
|
12
|
+
[lints]
|
|
13
|
+
workspace = true
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
ruby-prism.workspace = true
|
|
17
|
+
thiserror.workspace = true
|
|
18
|
+
|
|
19
|
+
[dev-dependencies]
|
|
20
|
+
tempfile.workspace = true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# padded_cell
|
|
2
|
+
|
|
3
|
+
Static Minitest discovery and deterministic test scheduling: the Rust core of
|
|
4
|
+
the padded_cell gem's native extension (`../ffi` binds it to Ruby). Internal,
|
|
5
|
+
not published. It has no Ruby types: parse test files with
|
|
6
|
+
[Prism](https://github.com/ruby/prism) and plan a sharded, seeded,
|
|
7
|
+
longest-first run.
|
|
8
|
+
|
|
9
|
+
```rust
|
|
10
|
+
let source = br#"
|
|
11
|
+
class UsersControllerTest < ActionDispatch::IntegrationTest
|
|
12
|
+
test "index lists users" do
|
|
13
|
+
get users_url
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_show
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
"#;
|
|
20
|
+
|
|
21
|
+
let found = padded_cell::discover(source);
|
|
22
|
+
let ids: Vec<String> = found.iter().map(|test| test.id()).collect();
|
|
23
|
+
assert_eq!(ids, ["UsersControllerTest#test_index_lists_users", "UsersControllerTest#test_show"]);
|
|
24
|
+
let first = found.iter().next().ok_or("no test")?;
|
|
25
|
+
assert_eq!((first.start_line, first.end_line), (3, 5));
|
|
26
|
+
assert_eq!(found.classes(), ["UsersControllerTest"]); // stored once
|
|
27
|
+
|
|
28
|
+
// One batch per class, slowest first. Shard 1 of 2, seed 42, no timings yet
|
|
29
|
+
// (-1.0 means unknown).
|
|
30
|
+
let classes: Vec<&str> = found.iter().map(|test| test.class_name).collect();
|
|
31
|
+
let batches = padded_cell::plan(&classes, &[-1.0, -1.0], 42, 0, 2);
|
|
32
|
+
assert!(batches.iter().flatten().all(|&index| index < ids.len()));
|
|
33
|
+
# Ok::<(), Box<dyn std::error::Error>>(())
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `discover` finds `def test_*` methods and Rails `test "..."` declarations in
|
|
37
|
+
classes whose superclass ends in `Test` or `TestCase`, with their line
|
|
38
|
+
spans. Nothing is evaluated, and syntax errors do not stop it. Each class
|
|
39
|
+
name is stored once; tests borrow it.
|
|
40
|
+
- `discover_files` reads and parses many files across cores, on scoped
|
|
41
|
+
threads (a process can fork right after). `discover_many` does the same for
|
|
42
|
+
sources already in memory.
|
|
43
|
+
- `plan` keeps tests grouped by class (per-class setup such as fixtures loads
|
|
44
|
+
once), shards classes by FNV-1a hash, shuffles with SplitMix64, and puts
|
|
45
|
+
the slowest classes, and within them the slowest tests, first. Class names
|
|
46
|
+
are any bytes (`AsRef<[u8]>`).
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
use std::assert_matches;
|
|
2
|
+
use std::io::ErrorKind;
|
|
3
|
+
|
|
4
|
+
use super::*;
|
|
5
|
+
|
|
6
|
+
fn ids(source: &str) -> Vec<String> {
|
|
7
|
+
ids_of(&discover(source.as_bytes()))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn test_classes_are_named_by_their_superclass() {
|
|
12
|
+
let cases: &[(&str, &[&str])] = &[
|
|
13
|
+
("class ATest < Minitest::Test\n def test_a; end\nend", &["ATest#test_a"]),
|
|
14
|
+
("class B < ActiveSupport::TestCase\n def test_b; end\nend", &["B#test_b"]),
|
|
15
|
+
("class C < BaseTest\n def test_c; end\nend", &["C#test_c"]),
|
|
16
|
+
("class DTest < Object\n def test_d; end\nend", &[]),
|
|
17
|
+
("class ETest\n def test_e; end\nend", &[]),
|
|
18
|
+
("class FTest < Testing\n def test_f; end\nend", &[]),
|
|
19
|
+
];
|
|
20
|
+
for (source, expected) in cases {
|
|
21
|
+
assert_eq!(ids(source), *expected, "source: {source:?}");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[test]
|
|
26
|
+
fn rails_test_names_replace_whitespace_runs() {
|
|
27
|
+
// A no-break space is not Ruby's \s: it stays.
|
|
28
|
+
let source = concat!(
|
|
29
|
+
"class ATest < ActiveSupport::TestCase\n",
|
|
30
|
+
" test \"a \\t\\v\\f\\r\\n b\u{a0}c\" do\n",
|
|
31
|
+
" end\n",
|
|
32
|
+
"end",
|
|
33
|
+
);
|
|
34
|
+
assert_eq!(ids(source), ["ATest#test_a_b\u{a0}c"]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[test]
|
|
38
|
+
fn lines_are_one_based_and_span_the_definition() {
|
|
39
|
+
let source = "class ATest < Minitest::Test\n\n def test_a\n x\n end\n test 'b'\nend\n";
|
|
40
|
+
let found = discover(source.as_bytes());
|
|
41
|
+
let lines: Vec<_> = found.iter().map(|test| (test.start_line, test.end_line)).collect();
|
|
42
|
+
assert_eq!(lines, [(3, 5), (6, 6)]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[test]
|
|
46
|
+
fn class_names_are_stored_once_and_only_with_tests() {
|
|
47
|
+
let source = concat!(
|
|
48
|
+
"class BaseTest < Minitest::Test\nend\n",
|
|
49
|
+
"module Admin\n class ATest < BaseTest\n def test_a; end\n test 'b'\n end\nend\n",
|
|
50
|
+
);
|
|
51
|
+
let found = discover(source.as_bytes());
|
|
52
|
+
assert_eq!(found.classes(), ["Admin::ATest"]);
|
|
53
|
+
assert_eq!(found.iter().map(|test| test.class).collect::<Vec<_>>(), [0, 0]);
|
|
54
|
+
assert_eq!(found.iter().len(), 2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[test]
|
|
58
|
+
fn invalid_utf8_names_are_skipped() {
|
|
59
|
+
let source = "class ATest < Minitest::Test\n test \"\\xFF\" do\n end\nend";
|
|
60
|
+
assert_eq!(ids(source), Vec::<String>::new());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[test]
|
|
64
|
+
fn discover_many_keeps_input_order() {
|
|
65
|
+
let sources: Vec<String> = (0..64)
|
|
66
|
+
.map(|i| format!("class T{i}Test < Minitest::Test\n def test_{i}; end\nend"))
|
|
67
|
+
.collect();
|
|
68
|
+
let found = discover_many(&sources);
|
|
69
|
+
assert_eq!(found.len(), sources.len());
|
|
70
|
+
for (i, discovery) in found.iter().enumerate() {
|
|
71
|
+
assert_eq!(ids_of(discovery), [format!("T{i}Test#test_{i}")]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[test]
|
|
76
|
+
fn discover_files_keeps_input_order() {
|
|
77
|
+
let dir = tempfile::tempdir().unwrap();
|
|
78
|
+
let paths: Vec<PathBuf> = (0..32)
|
|
79
|
+
.map(|i| {
|
|
80
|
+
let path = dir.path().join(format!("t{i}_test.rb"));
|
|
81
|
+
fs::write(&path, format!("\nclass T{i}Test < Minitest::Test\n test '{i}'\nend\n"))
|
|
82
|
+
.unwrap();
|
|
83
|
+
path
|
|
84
|
+
})
|
|
85
|
+
.collect();
|
|
86
|
+
let found = discover_files(&paths).unwrap();
|
|
87
|
+
for (i, discovery) in found.iter().enumerate() {
|
|
88
|
+
let tests: Vec<_> = discovery.iter().map(|test| (test.id(), test.start_line)).collect();
|
|
89
|
+
assert_eq!(tests, [(format!("T{i}Test#test_{i}"), 3)]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn discover_files_reports_the_first_unreadable_file() {
|
|
95
|
+
let dir = tempfile::tempdir().unwrap();
|
|
96
|
+
let readable = dir.path().join("a_test.rb");
|
|
97
|
+
fs::write(&readable, "").unwrap();
|
|
98
|
+
let first_missing = dir.path().join("b_test.rb");
|
|
99
|
+
let paths = [readable, first_missing.clone(), dir.path().join("c_test.rb")];
|
|
100
|
+
let error = discover_files(&paths).unwrap_err();
|
|
101
|
+
assert_eq!(error.path, first_missing);
|
|
102
|
+
assert_matches!(error.source.kind(), ErrorKind::NotFound);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn ids_of(discovery: &Discovery) -> Vec<String> {
|
|
106
|
+
discovery.iter().map(|test| test.id()).collect()
|
|
107
|
+
}
|