padded_cell 0.1.0-aarch64-linux-musl
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/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/exe/padded_cell +6 -0
- data/lib/padded_cell/4.0/padded_cell_native.so +0 -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 +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 81bd623809416824f228b0ef5425f04dbd55256dd8e9f619cc26ea32fe0350bc
|
|
4
|
+
data.tar.gz: 5c6adc84d4a5f65c6f72f542ad9f07ae4ba454feee194b672de7ce54ef2df630
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6719550f356599f156114a2252fe7de3b678987039d15c281eef821f125ee3d8d747a75d4f726ed67c6fd3726b34d021753f63386d46ecb789e00a470bf9818b
|
|
7
|
+
data.tar.gz: a5e3b1795e07f22d31ca23b9b88f378f578dfd59ae2fd472a36399f868eb9fabec0b90f6f2f4e7f394017514def07c15fbf195491b4848188207c8f303230e67
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
First release, for CRuby 4.0+ and Minitest 6 on Linux, macOS and FreeBSD: a
|
|
6
|
+
drop-in runner for Minitest and Rails test suites. Windows, JRuby and
|
|
7
|
+
TruffleRuby are not supported.
|
|
8
|
+
|
|
9
|
+
- Fork-per-test cells (`--isolation test`) or long-lived workers
|
|
10
|
+
(`--isolation worker`); crashes, `exit`, `exit!` and `--timeout` become
|
|
11
|
+
error results, and dead workers are replaced.
|
|
12
|
+
- Static discovery with Prism: `file:line[-line]`, `--list` and `--shard I/N`
|
|
13
|
+
without loading tests.
|
|
14
|
+
- Tests grouped by class (fixtures load once per class), seeded order,
|
|
15
|
+
slowest classes and tests first from the previous run's timings; idle
|
|
16
|
+
workers split big classes. `--shard` splits by class.
|
|
17
|
+
- Worker isolation by default when Active Record fixtures are loaded.
|
|
18
|
+
- Minitest's reporters and plugins, plus `--junit` and `--json` reports.
|
|
19
|
+
- `PaddedCell.before_fork` / `after_fork` / `cleanup` hooks; ActiveSupport's
|
|
20
|
+
parallelization hooks and Active Record's per-worker test databases.
|
|
21
|
+
- Discovery and scheduling in Rust, as a native extension: precompiled gems
|
|
22
|
+
for Linux, macOS and FreeBSD, a source gem that compiles it elsewhere.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdelkader Boudih
|
|
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,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
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/testing/parallelization'
|
|
4
|
+
|
|
5
|
+
module PaddedCell
|
|
6
|
+
# Runs ActiveSupport's parallelization hooks around padded_cell's workers,
|
|
7
|
+
# as Rails runs them around its own: parallelize_before_fork,
|
|
8
|
+
# parallelize_setup, parallelize_teardown, and Active Record's per-worker
|
|
9
|
+
# test databases (app_test-0, app_test-1...).
|
|
10
|
+
#
|
|
11
|
+
# The runner installs this after loading the tests when ActiveSupport::TestCase
|
|
12
|
+
# is defined. Any parallelize(workers:) call in test_helper.rb stays inert:
|
|
13
|
+
# padded_cell is the executor.
|
|
14
|
+
module ActiveSupportHooks
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
return if @installed
|
|
19
|
+
|
|
20
|
+
@installed = true
|
|
21
|
+
parallelization = ActiveSupport::Testing::Parallelization
|
|
22
|
+
PaddedCell.before_fork { parallelization.before_fork_hooks.each(&:call) }
|
|
23
|
+
PaddedCell.after_fork do |worker|
|
|
24
|
+
ActiveSupport::TestCase.parallel_worker_id = worker if ActiveSupport::TestCase.respond_to?(:parallel_worker_id=)
|
|
25
|
+
parallelization.after_fork_hooks.each { it.call(worker) }
|
|
26
|
+
end
|
|
27
|
+
PaddedCell.cleanup { |worker| parallelization.run_cleanup_hooks.each { it.call(worker) } }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# One test in its own forked process. Whatever the test does to its process
|
|
5
|
+
# (crash, exit, hang, monkey patches, leaked threads) stays in the cell.
|
|
6
|
+
module Cell
|
|
7
|
+
CHUNK = 65_536
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Runs klass#name in a fork and returns its Minitest::Result, or an error
|
|
12
|
+
# result when the cell died or ran past timeout seconds.
|
|
13
|
+
#
|
|
14
|
+
# close: descriptors the cell must not keep open (the worker's socket)
|
|
15
|
+
def run(klass, name, seed:, timeout: nil, close: [])
|
|
16
|
+
# Binary: the result is Marshal data, and with Encoding.default_internal
|
|
17
|
+
# set (Rails sets it) a text-mode pipe transcodes what is written to it.
|
|
18
|
+
reader, writer = IO.pipe
|
|
19
|
+
[reader, writer].each(&:binmode)
|
|
20
|
+
started = Minitest.clock_time
|
|
21
|
+
pid = fork_cell(klass, name, seed, reader, writer, close)
|
|
22
|
+
writer.close
|
|
23
|
+
collect(pid, reader, klass, name, started, timeout)
|
|
24
|
+
ensure
|
|
25
|
+
reader&.close
|
|
26
|
+
writer&.close unless writer&.closed?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs klass#name in this process. A test calling exit is reported as an
|
|
30
|
+
# error instead of ending the process.
|
|
31
|
+
def execute(klass, name)
|
|
32
|
+
klass.new(name).run
|
|
33
|
+
rescue SystemExit => e
|
|
34
|
+
Results.crashed(klass, name, "exit(#{e.status}) called during the test")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fork_cell(klass, name, seed, reader, writer, close)
|
|
38
|
+
$stdout.flush
|
|
39
|
+
$stderr.flush
|
|
40
|
+
Process.fork do
|
|
41
|
+
status = 1
|
|
42
|
+
begin
|
|
43
|
+
reader.close
|
|
44
|
+
close.each(&:close)
|
|
45
|
+
srand(seed)
|
|
46
|
+
writer.write(Results.dump(execute(klass, name)))
|
|
47
|
+
status = 0
|
|
48
|
+
rescue Exception => e # anything: report it, then exit!
|
|
49
|
+
warn "padded_cell: cell #{klass}##{name} failed to report: #{e.class}: #{e.message}"
|
|
50
|
+
ensure
|
|
51
|
+
# exit! skips at_exit handlers: the cell is not the test run.
|
|
52
|
+
exit!(status)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def collect(pid, reader, klass, name, started, timeout)
|
|
58
|
+
data = read(reader, timeout && (started + timeout))
|
|
59
|
+
elapsed = Minitest.clock_time - started
|
|
60
|
+
if data.nil?
|
|
61
|
+
Process.kill(:KILL, pid)
|
|
62
|
+
Process.wait(pid)
|
|
63
|
+
return Results.crashed(klass, name, "timed out after #{timeout}s", time: elapsed)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
_, status = Process.wait2(pid)
|
|
67
|
+
return Results.load(data, klass, name) unless data.empty?
|
|
68
|
+
|
|
69
|
+
Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Everything until EOF, or nil when the deadline passes first.
|
|
73
|
+
def read(io, deadline)
|
|
74
|
+
data = String.new(encoding: Encoding::BINARY)
|
|
75
|
+
loop do
|
|
76
|
+
remaining = deadline && (deadline - Minitest.clock_time)
|
|
77
|
+
return if remaining&.<= 0
|
|
78
|
+
next unless io.wait_readable(remaining)
|
|
79
|
+
|
|
80
|
+
chunk = io.read_nonblock(CHUNK, exception: false)
|
|
81
|
+
return data if chunk.nil?
|
|
82
|
+
|
|
83
|
+
data << chunk if chunk.is_a?(String)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative 'runner'
|
|
5
|
+
|
|
6
|
+
module PaddedCell
|
|
7
|
+
# The padded_cell command.
|
|
8
|
+
module CLI
|
|
9
|
+
BANNER = <<~TEXT
|
|
10
|
+
Usage: padded_cell [options] [paths...]
|
|
11
|
+
|
|
12
|
+
Runs a Minitest (or Rails) test suite, every test in its own forked cell.
|
|
13
|
+
|
|
14
|
+
Paths are files, directories, globs or file:line[-line][:line...].
|
|
15
|
+
Without paths: test/**/*_test.rb, minus test/{system,dummy,fixtures}.
|
|
16
|
+
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Exit status: 0 passed, 1 failed, 2 usage or load error, 130 interrupted.
|
|
22
|
+
def start(argv, io: $stdout)
|
|
23
|
+
options = { io: }
|
|
24
|
+
parser(options).parse!(argv)
|
|
25
|
+
options[:paths] = argv
|
|
26
|
+
Runner.new(**options).run
|
|
27
|
+
rescue OptionParser::ParseError, ArgumentError, LoadFailure, Unsupported, SystemCallError => e
|
|
28
|
+
warn "padded_cell: #{e.message}"
|
|
29
|
+
2
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parser(options)
|
|
33
|
+
OptionParser.new do |opts|
|
|
34
|
+
opts.banner = BANNER
|
|
35
|
+
opts.version = VERSION
|
|
36
|
+
runner_options(opts, options)
|
|
37
|
+
output_options(opts, options)
|
|
38
|
+
opts.on('--version', 'Print the version') do
|
|
39
|
+
options[:io].puts "padded_cell #{VERSION}"
|
|
40
|
+
exit
|
|
41
|
+
end
|
|
42
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
43
|
+
options[:io].puts opts
|
|
44
|
+
exit
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def runner_options(opts, options)
|
|
50
|
+
opts.on('-j', '--jobs N', Integer, "Workers (default: #{Etc.nprocessors})") do
|
|
51
|
+
options[:jobs] = positive(it)
|
|
52
|
+
end
|
|
53
|
+
opts.on('-s', '--seed SEED', Integer, 'Order seed (default: random, or $SEED)') { options[:seed] = it }
|
|
54
|
+
opts.on('-n', '-i', '--name', '--include PATTERN', 'Only tests matching (/regexp/ or exact name)') do
|
|
55
|
+
options[:include] = it
|
|
56
|
+
end
|
|
57
|
+
opts.on('-e', '-x', '--exclude PATTERN', 'Skip tests matching (/regexp/ or exact name)') do
|
|
58
|
+
options[:exclude] = it
|
|
59
|
+
end
|
|
60
|
+
opts.on('--shard I/N', 'Run shard I of N (1-based), split by class') { options[:shard] = shard(it) }
|
|
61
|
+
opts.on('--isolation MODE', %w[test worker], 'test: a fork per test; worker: tests share their worker ' \
|
|
62
|
+
'(default: worker with Active Record fixtures, else test)') do
|
|
63
|
+
options[:isolation] = it.to_sym
|
|
64
|
+
end
|
|
65
|
+
opts.on('--timeout SECONDS', Float, 'Kill and fail a test running longer') do
|
|
66
|
+
options[:timeout] = positive(it)
|
|
67
|
+
end
|
|
68
|
+
opts.on('--fail-fast', 'Stop dispatching after the first failure') { options[:fail_fast] = true }
|
|
69
|
+
opts.on('--list', 'List the tests that would run (static, loads nothing)') { options[:list] = true }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def output_options(opts, options)
|
|
73
|
+
opts.on('-v', '--verbose', 'One line per test, with timings') { options[:verbose] = true }
|
|
74
|
+
opts.on('--show-skips', 'Show skipped tests at the end') { options[:show_skips] = true }
|
|
75
|
+
opts.on('--junit PATH', 'Also write JUnit XML to PATH') { options[:junit] = it }
|
|
76
|
+
opts.on('--json PATH', 'Also write JSON lines to PATH') { options[:json] = it }
|
|
77
|
+
opts.on('--timings PATH', 'Timings file (default: tmp/padded_cell/timings.tsv)') { options[:timings] = it }
|
|
78
|
+
opts.on('--no-timings', 'Neither read nor write timings') { options[:timings] = nil }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# OptionParser names the switch in the message.
|
|
82
|
+
def positive(value)
|
|
83
|
+
raise OptionParser::InvalidArgument, "#{value} (must be positive)" unless value.positive?
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def shard(value)
|
|
89
|
+
index, count = value.split('/', 2).map { Integer(it, 10, exception: false) }
|
|
90
|
+
unless index && count&.positive? && index.between?(1, count)
|
|
91
|
+
raise OptionParser::InvalidArgument, "--shard #{value} (expected I/N, 1 <= I <= N)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
[index - 1, count]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# A statically discovered test.
|
|
5
|
+
TestRef = Data.define(:file, :class_name, :name, :start_line, :end_line) do
|
|
6
|
+
def id = "#{class_name}##{name}"
|
|
7
|
+
|
|
8
|
+
def lines = start_line..end_line
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Static discovery, without loading anything.
|
|
12
|
+
module Discovery
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# TestRefs for files, in file order, then source order. The extension
|
|
16
|
+
# reads and parses the files itself, on every core.
|
|
17
|
+
def call(files) = PaddedCellNative.discover_files(files).zip(files).flat_map { |rows, file| refs(rows, file) }
|
|
18
|
+
|
|
19
|
+
# TestRefs for one source string.
|
|
20
|
+
def source(source, file: '(source)') = refs(PaddedCellNative.discover(source), file)
|
|
21
|
+
|
|
22
|
+
def refs(rows, file) = rows.map { TestRef.new(file, *it) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# Process lifecycle hooks, for anything that must not be shared across
|
|
5
|
+
# forks (database connections, sockets, per-worker resources):
|
|
6
|
+
#
|
|
7
|
+
# PaddedCell.before_fork { Database.disconnect }
|
|
8
|
+
# PaddedCell.after_fork { |worker| Database.connect("app_test_#{worker}") }
|
|
9
|
+
# PaddedCell.cleanup { |worker| Database.drop("app_test_#{worker}") }
|
|
10
|
+
#
|
|
11
|
+
# before_fork runs in the runner before workers start, after_fork in each
|
|
12
|
+
# worker (worker index 0...jobs) and cleanup when a worker finishes.
|
|
13
|
+
# Cells (one fork per test) run no hooks: they inherit their worker.
|
|
14
|
+
module Hooks
|
|
15
|
+
@before_fork = []
|
|
16
|
+
@after_fork = []
|
|
17
|
+
@cleanup = []
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def before_fork(&block) = @before_fork << block
|
|
21
|
+
|
|
22
|
+
def after_fork(&block) = @after_fork << block
|
|
23
|
+
|
|
24
|
+
def cleanup(&block) = @cleanup << block
|
|
25
|
+
|
|
26
|
+
def run_before_fork = @before_fork.each(&:call)
|
|
27
|
+
|
|
28
|
+
def run_after_fork(worker) = @after_fork.each { it.call(worker) }
|
|
29
|
+
|
|
30
|
+
def run_cleanup(worker) = @cleanup.each { it.call(worker) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddedCell
|
|
4
|
+
# A test file that would not load.
|
|
5
|
+
class LoadFailure < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Loads test files once, in the runner, so every worker and cell starts
|
|
8
|
+
# from the same preloaded (copy-on-write) process.
|
|
9
|
+
module Loader
|
|
10
|
+
# Test helpers require minitest/autorun (or Rails' equivalent): its
|
|
11
|
+
# at_exit would run the whole suite again, unisolated.
|
|
12
|
+
module NoAutorun
|
|
13
|
+
def autorun = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(files, seed:)
|
|
19
|
+
Minitest.singleton_class.prepend(NoAutorun)
|
|
20
|
+
Minitest.seed = seed
|
|
21
|
+
# What `rake test` and `rails test` put on the load path.
|
|
22
|
+
%w[test lib].each do |dir|
|
|
23
|
+
path = File.expand_path(dir)
|
|
24
|
+
$LOAD_PATH.unshift(path) if File.directory?(path) && !$LOAD_PATH.include?(path)
|
|
25
|
+
end
|
|
26
|
+
files.each { load_file(it) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load_file(file)
|
|
30
|
+
require file
|
|
31
|
+
rescue ScriptError, StandardError => e
|
|
32
|
+
raise LoadFailure, "#{file}: #{e.class}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# [klass, method_name] for every loaded test, the way Minitest finds them.
|
|
36
|
+
def tests
|
|
37
|
+
Minitest::Runnable.runnables.flat_map do |klass|
|
|
38
|
+
klass.runnable_methods.map { [klass, it] }
|
|
39
|
+
rescue NotImplementedError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Which loaded tests run: Minitest's --include/--exclude, plus file:line.
|
|
46
|
+
class Filter
|
|
47
|
+
def initialize(selection, refs, include: nil, exclude: nil)
|
|
48
|
+
@selection = selection
|
|
49
|
+
@include = pattern(include)
|
|
50
|
+
@exclude = pattern(exclude)
|
|
51
|
+
@ends = refs.to_h { [[it.file, it.start_line], it.end_line] }
|
|
52
|
+
@selected = selection.files.to_h { [it, true] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(klass, name)
|
|
56
|
+
id = "#{klass}##{name}"
|
|
57
|
+
return false if @include && !matches?(@include, name, id)
|
|
58
|
+
return false if @exclude && matches?(@exclude, name, id)
|
|
59
|
+
|
|
60
|
+
!@selection.line_filtered? || line_selected?(klass, name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# "/re/" is a Regexp, anything else an exact name, as in Minitest.
|
|
66
|
+
def pattern(value)
|
|
67
|
+
match = value.is_a?(String) && %r{\A/(.*)/\z}.match(value)
|
|
68
|
+
match ? Regexp.new(match[1]) : value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def matches?(pattern, name, id) = [name, id].any? { pattern === it }
|
|
72
|
+
|
|
73
|
+
# With file:line arguments, a test runs when its file was named and its
|
|
74
|
+
# definition overlaps a requested line, or its file was named whole.
|
|
75
|
+
def line_selected?(klass, name)
|
|
76
|
+
file, line = klass.instance_method(name).source_location
|
|
77
|
+
return false unless file && @selected[file]
|
|
78
|
+
|
|
79
|
+
@selection.covers?(file, line..@ends.fetch([file, line], line))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loads the Rust extension, which does discovery and scheduling. There is no
|
|
4
|
+
# fallback: platform gems ship it precompiled under the Ruby ABI version, the
|
|
5
|
+
# source gem compiles it at install, and a checkout builds it with
|
|
6
|
+
# `rake compile`.
|
|
7
|
+
|
|
8
|
+
require 'rbconfig'
|
|
9
|
+
|
|
10
|
+
shipped = File.join(__dir__, RUBY_VERSION[/\A\d+\.\d+/], "padded_cell_native.#{RbConfig::CONFIG['DLEXT']}")
|
|
11
|
+
begin
|
|
12
|
+
File.exist?(shipped) ? require(shipped) : require('padded_cell/padded_cell_native')
|
|
13
|
+
rescue LoadError => e
|
|
14
|
+
raise LoadError, "padded_cell: native extension not loaded (#{e.message}). Precompiled gems need " \
|
|
15
|
+
'glibc 2.34+ (Linux), macOS 13+ or FreeBSD 15.1; elsewhere the gem builds it with Rust ' \
|
|
16
|
+
'at install. In a checkout, run `rake compile`.'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A stale build may implement other semantics.
|
|
20
|
+
unless PaddedCellNative.version == PaddedCell::VERSION
|
|
21
|
+
raise LoadError, "padded_cell: native extension #{PaddedCellNative.version} does not match padded_cell " \
|
|
22
|
+
"#{PaddedCell::VERSION}: rebuild it (`rake compile` in a checkout, or reinstall the gem)"
|
|
23
|
+
end
|