padded_cell 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +38 -0
- data/Cargo.lock +16 -2
- data/Cargo.toml +2 -0
- data/README.md +170 -8
- data/ext/padded_cell_native/core/Cargo.toml +2 -1
- data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
- data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
- data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
- data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
- data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
- data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
- data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
- data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
- data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
- data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
- data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
- data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
- data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
- data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
- data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
- data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
- data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
- data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
- data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
- data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
- data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
- data/ext/padded_cell_native/core/src/chart.rs +302 -0
- data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
- data/ext/padded_cell_native/core/src/discover.rs +7 -53
- data/ext/padded_cell_native/core/src/error.rs +24 -0
- data/ext/padded_cell_native/core/src/lib.rs +10 -3
- data/ext/padded_cell_native/core/src/parallel.rs +43 -0
- data/ext/padded_cell_native/core/src/restarts/tests.rs +29 -0
- data/ext/padded_cell_native/core/src/restarts.rs +64 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +2 -1
- data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +5 -0
- data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
- data/lib/padded_cell/asylum.rb +93 -0
- data/lib/padded_cell/cell.rb +23 -14
- data/lib/padded_cell/chart/accumulator.rb +103 -0
- data/lib/padded_cell/chart/config.rb +129 -0
- data/lib/padded_cell/chart/delta.rb +148 -0
- data/lib/padded_cell/chart/document.rb +64 -0
- data/lib/padded_cell/chart/filter.rb +55 -0
- data/lib/padded_cell/chart/merge.rb +63 -0
- data/lib/padded_cell/chart/probe.rb +70 -0
- data/lib/padded_cell/chart/raw.rb +48 -0
- data/lib/padded_cell/chart/report.rb +128 -0
- data/lib/padded_cell/chart/source_files.rb +90 -0
- data/lib/padded_cell/chart.rb +38 -0
- data/lib/padded_cell/cli.rb +70 -4
- data/lib/padded_cell/limits.rb +45 -0
- data/lib/padded_cell/loader.rb +5 -1
- data/lib/padded_cell/pool.rb +82 -58
- data/lib/padded_cell/reporters.rb +20 -3
- data/lib/padded_cell/results.rb +13 -0
- data/lib/padded_cell/runner.rb +150 -22
- data/lib/padded_cell/version.rb +1 -1
- data/lib/padded_cell/worker.rb +63 -0
- data/lib/padded_cell.rb +8 -0
- metadata +43 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d2b2f1c6d06b6cf4e72a67cfb6d511cc121fad2794d5fffc84bbf323c8431f1
|
|
4
|
+
data.tar.gz: '08aacbd7f382259c1eee0b45e3508c7dba4522be0c6d57ba4ca13b496ba70a1b'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d473da59b1f2de1ae4cee4d423c8361ffcf42060bd8b4fd264894a82c6d631cdddd8e8ee376716ae0d73db4ea4e3aefbf540fdbd42b20ed996ca024740448aba
|
|
7
|
+
data.tar.gz: 4d7a2de1bad5aaf24df760011df2a9728193586640bb7f7f14a9dcd66d38750082995692736bdfc0a4ccb951fb174384d600ac723414a39e0c479d98203f2515
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-11-08)
|
|
4
|
+
|
|
5
|
+
- `--budget SECONDS`: a deadline for the whole run, loading included. Once
|
|
6
|
+
it's spent, nothing more is dispatched and running tests are killed. Tests
|
|
7
|
+
it left out fail the run (exit 1). `--timeout` never outlives it.
|
|
8
|
+
- Workers that keep dying (more than 4 per worker, at least 16, in 10s) are
|
|
9
|
+
no longer replaced forever. padded_cell gives up, reports how many tests
|
|
10
|
+
never ran, and exits 1. Uses `supervisor-machines`.
|
|
11
|
+
- A replacement worker that died before its first test crashed the run with
|
|
12
|
+
`Errno::EPIPE` (exit 2). A worker reset mid-frame is now reported as dead.
|
|
13
|
+
- `--asylum` retry seeds keep all 64 bits: they were folded modulo 0xffff,
|
|
14
|
+
so large `--seed`s shared the same few retry seeds.
|
|
15
|
+
|
|
16
|
+
## 0.2.0 (2026-10-11)
|
|
17
|
+
|
|
18
|
+
- Built-in coverage: `--coverage[=lines,branches,methods,eval]` (or
|
|
19
|
+
`PADDED_CELL_COVERAGE`) measures the runner, every worker and every cell,
|
|
20
|
+
and sums them exactly. Configured with `PaddedCell.coverage` in the test
|
|
21
|
+
helper: tracked files, filters, groups, the `:rails` profile. Honors
|
|
22
|
+
`# :nocov:` blocks and `# simplecov:disable` comments. Writes
|
|
23
|
+
`coverage.md` (the code that never ran, for agents, reviews and CI
|
|
24
|
+
summaries), `lcov.info`, and `coverage.json` / `.resultset.json`.
|
|
25
|
+
`minimum`, `minimum_by_file`, `minimum_by_group` and `maximum_drop` fail a
|
|
26
|
+
whole passing run with exit 3. `--coverage-merge` merges shards' reports.
|
|
27
|
+
- `--seed` must be 0 to 2**64 - 1; others crashed the planner.
|
|
28
|
+
- `--asylum[=N]`: retry failed tests (N times, default 2) after the run,
|
|
29
|
+
alone and in fresh cells. Tests that pass on retry count as passed and are
|
|
30
|
+
reported as flaky, with the likely cause (timing and shared state, or
|
|
31
|
+
randomness and order) and their first failure; JUnit gets
|
|
32
|
+
`<flakyFailure>`, JSON a `flaky` field.
|
|
33
|
+
- A `cleanup` hook that raises fails the run (exit 1), `--asylum` retries
|
|
34
|
+
included. It exited 0 when the tests passed.
|
|
35
|
+
- A passing run filtered with `--name` exits 0: it exited 1, reporting that
|
|
36
|
+
nothing ran.
|
|
37
|
+
- `--list` applies `--name` and `--exclude`, before `--shard`, as a run does.
|
|
38
|
+
- An invalid `--name` or `--exclude` regexp is a usage error (exit 2), not a
|
|
39
|
+
crash.
|
|
40
|
+
|
|
3
41
|
## 0.1.0 (2026-10-11)
|
|
4
42
|
|
|
5
43
|
First release, for CRuby 4.0+ and Minitest 6 on Linux, macOS and FreeBSD: a
|
data/Cargo.lock
CHANGED
|
@@ -73,6 +73,12 @@ dependencies = [
|
|
|
73
73
|
"libloading",
|
|
74
74
|
]
|
|
75
75
|
|
|
76
|
+
[[package]]
|
|
77
|
+
name = "deadline-machines"
|
|
78
|
+
version = "0.4.0"
|
|
79
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
80
|
+
checksum = "805cba0e8981fd367f11a17886e0ad53b84365601f5d39d8b66819f9bd0ba463"
|
|
81
|
+
|
|
76
82
|
[[package]]
|
|
77
83
|
name = "either"
|
|
78
84
|
version = "1.19.0"
|
|
@@ -220,17 +226,19 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
220
226
|
|
|
221
227
|
[[package]]
|
|
222
228
|
name = "padded_cell"
|
|
223
|
-
version = "0.
|
|
229
|
+
version = "0.3.0"
|
|
224
230
|
dependencies = [
|
|
225
231
|
"ruby-prism",
|
|
232
|
+
"supervisor-machines",
|
|
226
233
|
"tempfile",
|
|
227
234
|
"thiserror",
|
|
228
235
|
]
|
|
229
236
|
|
|
230
237
|
[[package]]
|
|
231
238
|
name = "padded_cell_native"
|
|
232
|
-
version = "0.
|
|
239
|
+
version = "0.3.0"
|
|
233
240
|
dependencies = [
|
|
241
|
+
"deadline-machines",
|
|
234
242
|
"magnus",
|
|
235
243
|
"padded_cell",
|
|
236
244
|
]
|
|
@@ -435,6 +443,12 @@ version = "2.0.1"
|
|
|
435
443
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
436
444
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
437
445
|
|
|
446
|
+
[[package]]
|
|
447
|
+
name = "supervisor-machines"
|
|
448
|
+
version = "0.4.0"
|
|
449
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
450
|
+
checksum = "89f7646079e8a1b802737bb514b9c68e8e0743c046c9fb3c570b50e38ba03121"
|
|
451
|
+
|
|
438
452
|
[[package]]
|
|
439
453
|
name = "syn"
|
|
440
454
|
version = "2.0.119"
|
data/Cargo.toml
CHANGED
|
@@ -13,6 +13,8 @@ repository = "https://github.com/seuros/padded_cell"
|
|
|
13
13
|
padded_cell = { path = "ext/padded_cell_native/core" }
|
|
14
14
|
magnus = { version = "0.9.2", default-features = false }
|
|
15
15
|
ruby-prism = "1.9.0"
|
|
16
|
+
deadline_machines = { package = "deadline-machines", version = "0.4.0", default-features = false }
|
|
17
|
+
supervisor_machines = { package = "supervisor-machines", version = "0.4.0", default-features = false }
|
|
16
18
|
tempfile = "3.27.0"
|
|
17
19
|
thiserror = "2.0.21"
|
|
18
20
|
|
data/README.md
CHANGED
|
@@ -69,14 +69,18 @@ $ padded_cell user_test.rb:12-30:41 # tests overlapping 12..30, or spanni
|
|
|
69
69
|
$ padded_cell -n /refund/ -e test_slow # Minitest's --include / --exclude
|
|
70
70
|
$ padded_cell -j 8 --seed 1234 # 8 workers, reproducible order
|
|
71
71
|
$ padded_cell --timeout 30 # kill and fail anything running 30s
|
|
72
|
+
$ padded_cell --budget 600 # the whole run gets 10 minutes; what's left out fails it
|
|
72
73
|
$ padded_cell --fail-fast
|
|
74
|
+
$ padded_cell --asylum # retry failures; report the flaky ones (see below)
|
|
73
75
|
$ padded_cell --list # what would run, without loading anything
|
|
74
76
|
$ padded_cell --shard 2/4 # CI: the second quarter of the classes
|
|
75
77
|
$ padded_cell --junit tmp/junit.xml --json tmp/results.jsonl
|
|
78
|
+
$ PADDED_CELL_JSON=tmp/results.jsonl padded_cell # same, from a CI runner's env
|
|
79
|
+
$ padded_cell --coverage # line and branch coverage (see Coverage)
|
|
76
80
|
```
|
|
77
81
|
|
|
78
82
|
Exit status: 0 when everything passed, 1 otherwise, 2 for usage or load
|
|
79
|
-
errors, 130 when interrupted.
|
|
83
|
+
errors, 3 when only coverage is below a threshold, 130 when interrupted.
|
|
80
84
|
|
|
81
85
|
### How a run works
|
|
82
86
|
|
|
@@ -108,11 +112,60 @@ cheap, and order-dependent tests stop depending on order.
|
|
|
108
112
|
Rails' `parallelize`. A worker that crashes or times out is still reported
|
|
109
113
|
and replaced.
|
|
110
114
|
|
|
115
|
+
Replaced up to a point. If workers keep dying, say a boot that segfaults or an
|
|
116
|
+
`after_fork` hook that calls `exit`, every remaining test would come back as a
|
|
117
|
+
crash report, one fork at a time. So past 4 deaths per worker in 10 seconds
|
|
118
|
+
(16 at least), padded_cell gives up: it lists how many tests never ran and
|
|
119
|
+
exits 1. The decision comes from
|
|
120
|
+
[supervisor_machines](https://github.com/seuros/supervisor_machines), the same
|
|
121
|
+
one-for-one restart intensity OTP uses.
|
|
122
|
+
|
|
123
|
+
### Budget
|
|
124
|
+
|
|
125
|
+
`--budget SECONDS` caps the whole run, loading included. Once it's spent,
|
|
126
|
+
nothing more is dispatched, and a test still running is killed and fails
|
|
127
|
+
("run budget of 600.0s spent"). A test's `--timeout` never outlives the
|
|
128
|
+
budget. If the budget left tests out, the run fails and says how many. The
|
|
129
|
+
deadline math comes from
|
|
130
|
+
[deadline_machines](https://github.com/seuros/deadline_machines).
|
|
131
|
+
|
|
111
132
|
The default is `test`, except when Active Record fixtures are loaded: a fork
|
|
112
133
|
discards Active Record's connection pool, and every test would reload its
|
|
113
134
|
fixtures, so those suites default to `worker`. The `Run options:` line shows
|
|
114
135
|
which one a run uses; `--isolation` overrides it.
|
|
115
136
|
|
|
137
|
+
### Flaky tests
|
|
138
|
+
|
|
139
|
+
`--asylum[=N]` gives failed tests up to N more attempts (default 2) once the
|
|
140
|
+
run is over: one test at a time, each in a fresh cell. A test that passes on
|
|
141
|
+
retry counts as passed, but padded_cell shows every attempt, its seed, and
|
|
142
|
+
what made the difference:
|
|
143
|
+
|
|
144
|
+
```console
|
|
145
|
+
4 runs, 4 assertions, 1 failures, 0 errors, 0 skips
|
|
146
|
+
|
|
147
|
+
You're not crazy, the world is. 2 tests failed, then passed:
|
|
148
|
+
|
|
149
|
+
FlakyTest#test_passes_when_retried
|
|
150
|
+
seed 1 lost the race
|
|
151
|
+
seed 1 passed, alone → timing, contention or state left by other tests
|
|
152
|
+
|
|
153
|
+
FlakyTest#test_depends_on_the_seed
|
|
154
|
+
seed 1 unlucky seed.
|
|
155
|
+
seed 1 unlucky seed. (alone)
|
|
156
|
+
seed 15472 passed, alone → randomness or test order
|
|
157
|
+
|
|
158
|
+
It's not the world, it's the test:
|
|
159
|
+
FlakyTest#test_always_fails failed all 3 attempts
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The first retry keeps the run's seed and runs alone, so passing there points
|
|
163
|
+
at timing, contention or leftover state; later retries change the seed, so
|
|
164
|
+
passing only there points at randomness or order. A test that fails every
|
|
165
|
+
attempt keeps its first failure, and the run fails. JUnit reports carry a
|
|
166
|
+
`<flakyFailure>` per flaky test; JSON lines carry the attempts in a `flaky`
|
|
167
|
+
field, or an `asylum` field for a test that never passed.
|
|
168
|
+
|
|
116
169
|
### Hooks
|
|
117
170
|
|
|
118
171
|
For anything that must not be shared across forks:
|
|
@@ -125,7 +178,115 @@ PaddedCell.cleanup { |worker| Database.drop("app_test_#{worker}") }
|
|
|
125
178
|
```
|
|
126
179
|
|
|
127
180
|
`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.
|
|
181
|
+
(`0...jobs`). Cells run no hooks: they inherit their worker's state. A
|
|
182
|
+
failing `after_fork` fails every test of its worker; a failing `cleanup`
|
|
183
|
+
fails the run, even when every test passed.
|
|
184
|
+
|
|
185
|
+
## Coverage
|
|
186
|
+
|
|
187
|
+
Ruby's `Coverage`, measured in the runner, in every worker and in every
|
|
188
|
+
cell, and summed exactly: a parallel run reports the counts of a serial one.
|
|
189
|
+
|
|
190
|
+
```console
|
|
191
|
+
$ padded_cell --coverage test/ # lines and branches
|
|
192
|
+
$ padded_cell --coverage=lines,branches,methods test/
|
|
193
|
+
$ PADDED_CELL_COVERAGE=1 bin/rails padded_cell
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Measuring starts before the suite loads, so what is measured (`lines`,
|
|
197
|
+
`branches`, `methods`, and `eval` for templates, experimental) comes from the
|
|
198
|
+
command line. The report is configured in the test helper:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# test/test_helper.rb
|
|
202
|
+
PaddedCell.coverage(:rails) do
|
|
203
|
+
track "{app,lib}/**/*.rb" # report files never loaded, at 0%
|
|
204
|
+
skip "app/legacy/" # also `filter`
|
|
205
|
+
group "Operations", "app/concepts" # string, regexp, array or block
|
|
206
|
+
group("Contracts") { |file| file.filename.include?("contract") }
|
|
207
|
+
minimum line: 90, branch: 80
|
|
208
|
+
minimum_by_file line: 50
|
|
209
|
+
minimum_by_group "Operations", line: 95
|
|
210
|
+
maximum_drop line: 1 # since the previous whole run
|
|
211
|
+
formats :markdown, :lcov, :json
|
|
212
|
+
dir "coverage" # --coverage-dir wins
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- Paths are matched relative to the project root. A string matches whole
|
|
217
|
+
path segments (`"app/models"` takes `app/models/user.rb`, not
|
|
218
|
+
`app/models_old/`), `"lib/"` a directory anywhere, `"contract.rb"` a file
|
|
219
|
+
name ending; a regexp matches the path; a block gets the file
|
|
220
|
+
(`filename`, `project_filename`, `src`).
|
|
221
|
+
- Left out by default: `vendor/bundle`, hidden files, `test/`, `spec/`,
|
|
222
|
+
`features/`. `clear_filters` drops these.
|
|
223
|
+
- `:rails` skips `config/` and `db/`, groups `app/channels`,
|
|
224
|
+
`app/controllers`, `app/helpers`, `app/jobs` and `app/workers`, `lib/`,
|
|
225
|
+
`app/mailers`, `app/models` and `app/views`, and tracks
|
|
226
|
+
`{app,lib}/**/*.rb`. A file in no group is Ungrouped.
|
|
227
|
+
- Thresholds compare percents floored to 2 decimals.
|
|
228
|
+
|
|
229
|
+
`PaddedCell.coverage` configures only; without `--coverage` nothing is
|
|
230
|
+
measured. Outside padded_cell, guard it with `if defined?(PaddedCell.coverage)`.
|
|
231
|
+
|
|
232
|
+
Into `coverage/` go, by format:
|
|
233
|
+
|
|
234
|
+
- `markdown`: `coverage.md`, for agents, reviews and CI summaries
|
|
235
|
+
(`cat coverage/coverage.md >> $GITHUB_STEP_SUMMARY`): the totals and
|
|
236
|
+
groups, then every file with code that never ran, the most missed first,
|
|
237
|
+
with its missed lines, branches and methods, and that code. It stops
|
|
238
|
+
listing files at 1 MB, the size of a GitHub job summary.
|
|
239
|
+
- `lcov`: `lcov.info` (`SF:./path`), for CI and editors.
|
|
240
|
+
- `json`: `coverage.json` and `.resultset.json`, in SimpleCov's formats, for
|
|
241
|
+
the tools reading them.
|
|
242
|
+
|
|
243
|
+
Also, every run: `.padded_cell.json` (what the report is made of, and what
|
|
244
|
+
`--coverage-merge` reads), and after a whole passing run `.last_run.json`.
|
|
245
|
+
|
|
246
|
+
Lines and branches inside `# :nocov:` blocks, and under
|
|
247
|
+
`# simplecov:disable [line|branch|method]` / `# simplecov:enable` comments,
|
|
248
|
+
are skipped. A directive after code skips its own line.
|
|
249
|
+
|
|
250
|
+
A run below a threshold exits 3. Thresholds hold for a whole, passing,
|
|
251
|
+
complete run only: they are not checked when tests failed, when tests were
|
|
252
|
+
picked by name or line, under `--shard`, or when a worker or cell died
|
|
253
|
+
before handing over what it measured (the report then says it is partial).
|
|
254
|
+
Reports are written either way, failing runs included.
|
|
255
|
+
|
|
256
|
+
A suite that starts SimpleCov as well is refused (exit 2): two tools cannot
|
|
257
|
+
share Ruby's `Coverage`.
|
|
258
|
+
|
|
259
|
+
### Shards
|
|
260
|
+
|
|
261
|
+
Each shard reports on its own; merge them, at the same commit, without
|
|
262
|
+
running anything:
|
|
263
|
+
|
|
264
|
+
```console
|
|
265
|
+
$ padded_cell --coverage --coverage-dir coverage/1 --shard 1/2 test/ # machine 1
|
|
266
|
+
$ padded_cell --coverage --coverage-dir coverage/2 --shard 2/2 test/ # machine 2
|
|
267
|
+
$ padded_cell --coverage-merge coverage/1,coverage/2 --coverage-dir coverage
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The merge refuses files that differ between shards or from the checkout,
|
|
271
|
+
and checks thresholds when every shard of the run is there. Lines that run
|
|
272
|
+
while the suite loads count once per shard.
|
|
273
|
+
|
|
274
|
+
### Cost
|
|
275
|
+
|
|
276
|
+
Measuring is opt-in: Ruby's instrumentation slows tests down. On a Rails
|
|
277
|
+
suite of 6,300 tests, lines and branches took the run from 465 s to 638 s of
|
|
278
|
+
CPU.
|
|
279
|
+
|
|
280
|
+
A worker hands over what it measured every 10 seconds or more: each time,
|
|
281
|
+
Ruby copies the coverage of every file it loaded, gems included (a few
|
|
282
|
+
milliseconds in a small app, a few hundred in a big Rails one), so the
|
|
283
|
+
interval stretches to keep this under 1% of the worker's time. A worker that
|
|
284
|
+
dies loses what it measured since. With `--isolation test` every cell pays
|
|
285
|
+
one such copy, at its end.
|
|
286
|
+
|
|
287
|
+
Files never loaded report their branches in the order Ruby compiles them.
|
|
288
|
+
With `eval`, code that `class_eval` runs from a heredoc counts as lines of
|
|
289
|
+
its file; directives in templates are not read.
|
|
129
290
|
|
|
130
291
|
## Rails
|
|
131
292
|
|
|
@@ -144,8 +305,7 @@ $ bin/rails padded_cell ARGS="-j 8 test/models"
|
|
|
144
305
|
Rails suites with fixtures run with `--isolation worker` unless told
|
|
145
306
|
otherwise (see [Isolation](#isolation)).
|
|
146
307
|
|
|
147
|
-
|
|
148
|
-
see only the runner, as with Rails' process parallelization.
|
|
308
|
+
For coverage, see [Coverage](#coverage).
|
|
149
309
|
|
|
150
310
|
## Library
|
|
151
311
|
|
|
@@ -164,7 +324,9 @@ PaddedCell::Scheduler.batches(class_names, durations: seconds, seed: 42, shard:
|
|
|
164
324
|
## Rust
|
|
165
325
|
|
|
166
326
|
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.
|
|
327
|
+
scheduling in plain Rust (no Ruby types), `ffi` binds it to Ruby. Worker
|
|
328
|
+
supervision and deadlines come from the published `supervisor-machines` and
|
|
329
|
+
`deadline-machines` crates. Neither is
|
|
168
330
|
published as a crate.
|
|
169
331
|
|
|
170
332
|
## Development
|
|
@@ -173,9 +335,9 @@ Needs Rust 1.99+ (`mise install` reads `mise.toml`).
|
|
|
173
335
|
|
|
174
336
|
```console
|
|
175
337
|
bundle install
|
|
176
|
-
rake test:all # cargo test, then
|
|
177
|
-
rake
|
|
178
|
-
rake
|
|
338
|
+
rake test:all # cargo test, then this suite run by padded_cell itself
|
|
339
|
+
rake dogfood # this suite, run by padded_cell (compiles the extension first)
|
|
340
|
+
rake test # this suite on plain Minitest, for when padded_cell itself is broken
|
|
179
341
|
rake lint # dictator, cargo fmt --check, clippy
|
|
180
342
|
```
|
|
181
343
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "padded_cell"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Static Minitest discovery and test scheduling for the padded_cell gem"
|
|
5
5
|
publish = false
|
|
6
6
|
edition.workspace = true
|
|
@@ -14,6 +14,7 @@ workspace = true
|
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
ruby-prism.workspace = true
|
|
17
|
+
supervisor_machines.workspace = true
|
|
17
18
|
thiserror.workspace = true
|
|
18
19
|
|
|
19
20
|
[dev-dependencies]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
fn skipped(text: &str) -> Skipped {
|
|
4
|
+
Skipped::of(&Source::new(text.as_bytes().to_vec()), "nocov")
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/// Where the `#` is, disable, and the criteria named.
|
|
8
|
+
type Found = Option<(usize, bool, [bool; 3])>;
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn directives_found_in_comments() {
|
|
12
|
+
const CASES: &[(&str, Found)] = &[
|
|
13
|
+
("# simplecov:disable", Some((0, true, [true; 3]))),
|
|
14
|
+
("#simplecov : enable", Some((0, false, [true; 3]))),
|
|
15
|
+
("# simplecov:disable branch", Some((0, true, [false, true, false]))),
|
|
16
|
+
("# simplecov:disable line , method because", Some((0, true, [true, false, true]))),
|
|
17
|
+
// The longest list ending at a word boundary.
|
|
18
|
+
("# simplecov:disable line, branchy", Some((0, true, [true, false, false]))),
|
|
19
|
+
("# simplecov:disable lines", Some((0, true, [true; 3]))),
|
|
20
|
+
("# note # simplecov:disable", Some((7, true, [true; 3]))),
|
|
21
|
+
("# simplecov:disabled", None),
|
|
22
|
+
("# simplecov disable", None),
|
|
23
|
+
("# rubocop:disable Style", None),
|
|
24
|
+
];
|
|
25
|
+
for (text, expected) in CASES {
|
|
26
|
+
let found = Directive::find(text.as_bytes())
|
|
27
|
+
.map(|(at, directive)| (at, directive.disable, directive.criteria));
|
|
28
|
+
assert_eq!(found, *expected, "comment: {text:?}");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[test]
|
|
33
|
+
fn blocks_inline_directives_and_nocov() {
|
|
34
|
+
let text = "\
|
|
35
|
+
a
|
|
36
|
+
# simplecov:disable branch
|
|
37
|
+
b
|
|
38
|
+
# simplecov:enable branch
|
|
39
|
+
c # simplecov:disable
|
|
40
|
+
# :nocov:
|
|
41
|
+
d
|
|
42
|
+
# :nocov:
|
|
43
|
+
# simplecov:disable method
|
|
44
|
+
e
|
|
45
|
+
";
|
|
46
|
+
let skipped = skipped(text);
|
|
47
|
+
assert_eq!(skipped.line, [6..=8, 5..=5]);
|
|
48
|
+
assert_eq!(skipped.branch, [6..=8, 2..=4, 5..=5]);
|
|
49
|
+
assert_eq!(skipped.method, [6..=8, 5..=5, 9..=10], "an open block runs to the end");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
fn only_real_comments_count() {
|
|
54
|
+
let text = "x = \"# simplecov:disable\"\ny = <<~TEXT\n # simplecov:disable\nTEXT\n";
|
|
55
|
+
assert_eq!(skipped(text), Skipped::default());
|
|
56
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
use std::ops::RangeInclusive;
|
|
2
|
+
|
|
3
|
+
use ruby_prism::CommentType;
|
|
4
|
+
|
|
5
|
+
use super::Source;
|
|
6
|
+
use super::source::{ruby_space, trim_start as skip_space};
|
|
7
|
+
|
|
8
|
+
#[cfg(test)]
|
|
9
|
+
mod tests;
|
|
10
|
+
|
|
11
|
+
/// Blocks of lines, inclusive.
|
|
12
|
+
pub type Blocks = Vec<RangeInclusive<u32>>;
|
|
13
|
+
|
|
14
|
+
/// The lines each criterion ignores.
|
|
15
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
16
|
+
pub struct Skipped {
|
|
17
|
+
pub line: Blocks,
|
|
18
|
+
pub branch: Blocks,
|
|
19
|
+
pub method: Blocks,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl Skipped {
|
|
23
|
+
#[must_use]
|
|
24
|
+
pub fn of(source: &Source, nocov_token: &str) -> Self {
|
|
25
|
+
let nocov = source.nocov(nocov_token);
|
|
26
|
+
let mut skipped = Self { line: nocov.clone(), branch: nocov.clone(), method: nocov };
|
|
27
|
+
// Parsing only sources that may hold one.
|
|
28
|
+
if source.bytes().windows(9).any(|window| window == b"simplecov") {
|
|
29
|
+
skipped.add_directives(source);
|
|
30
|
+
}
|
|
31
|
+
skipped
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn add_directives(&mut self, source: &Source) {
|
|
35
|
+
let result = ruby_prism::parse(source.bytes());
|
|
36
|
+
let mut open: [Option<u32>; 3] = [None; 3];
|
|
37
|
+
let comments =
|
|
38
|
+
result.comments().filter(|comment| comment.type_() == CommentType::InlineComment);
|
|
39
|
+
for comment in comments {
|
|
40
|
+
let Some((at, directive)) = Directive::find(comment.text()) else {
|
|
41
|
+
continue;
|
|
42
|
+
};
|
|
43
|
+
let offset = comment.location().start_offset().strict_add(at);
|
|
44
|
+
let (line, _) = source.position(offset);
|
|
45
|
+
let before = &source.bytes()[source.line_start(offset)..offset];
|
|
46
|
+
let inline = before.iter().any(|&byte| !ruby_lstrip_space(byte));
|
|
47
|
+
let blocks = self.blocks_mut().into_iter().zip(&mut open);
|
|
48
|
+
for ((blocks, open), _) in blocks.zip(directive.criteria).filter(|&(_, named)| named) {
|
|
49
|
+
directive.apply(line, inline, blocks, open);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let last = u32::try_from(source.len()).unwrap_or(u32::MAX);
|
|
53
|
+
for (blocks, open) in self.blocks_mut().into_iter().zip(open) {
|
|
54
|
+
blocks.extend(open.map(|start| start..=last));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fn blocks_mut(&mut self) -> [&mut Blocks; 3] {
|
|
59
|
+
[&mut self.line, &mut self.branch, &mut self.method]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// A `# simplecov:disable` or `enable`, and the criteria it names: line,
|
|
64
|
+
/// branch, method.
|
|
65
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
66
|
+
struct Directive {
|
|
67
|
+
disable: bool,
|
|
68
|
+
criteria: [bool; 3],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
impl Directive {
|
|
72
|
+
/// The first directive in a comment, and where its `#` is:
|
|
73
|
+
/// `/#\s*simplecov\s*:\s*(disable|enable)\b(?:\s+(criteria)\b)?/`.
|
|
74
|
+
fn find(text: &[u8]) -> Option<(usize, Self)> {
|
|
75
|
+
text.iter().enumerate().filter(|&(_, &byte)| byte == b'#').find_map(|(at, _)| {
|
|
76
|
+
Self::parse(&text[at.strict_add(1)..]).map(|directive| (at, directive))
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn parse(text: &[u8]) -> Option<Self> {
|
|
81
|
+
let text = skip_space(skip_space(text).strip_prefix(b"simplecov")?).strip_prefix(b":")?;
|
|
82
|
+
let text = skip_space(text);
|
|
83
|
+
let (disable, rest) = match text.strip_prefix(b"disable") {
|
|
84
|
+
Some(rest) => (true, rest),
|
|
85
|
+
None => (false, text.strip_prefix(b"enable")?),
|
|
86
|
+
};
|
|
87
|
+
if starts_with_word(rest) {
|
|
88
|
+
return None;
|
|
89
|
+
}
|
|
90
|
+
Some(Self { disable, criteria: criteria(rest).unwrap_or([true; 3]) })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// After code, a directive covers its own line; alone on its line, it
|
|
94
|
+
/// opens or closes a block (a block already open stays as it is).
|
|
95
|
+
fn apply(self, line: u32, inline: bool, blocks: &mut Blocks, open: &mut Option<u32>) {
|
|
96
|
+
if inline {
|
|
97
|
+
if self.disable {
|
|
98
|
+
blocks.push(line..=line);
|
|
99
|
+
}
|
|
100
|
+
} else if self.disable {
|
|
101
|
+
open.get_or_insert(line);
|
|
102
|
+
} else if let Some(start) = open.take() {
|
|
103
|
+
blocks.push(start..=line);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// `\s+(line|branch|method)(\s*,\s*(line|branch|method))*\b`, the longest
|
|
109
|
+
/// list that ends at a word boundary.
|
|
110
|
+
fn criteria(text: &[u8]) -> Option<[bool; 3]> {
|
|
111
|
+
if !text.first().is_some_and(|&byte| ruby_space(byte)) {
|
|
112
|
+
return None;
|
|
113
|
+
}
|
|
114
|
+
let (mut text, mut found, mut accepted) = (skip_space(text), [false; 3], None);
|
|
115
|
+
while let Some((index, rest)) = criterion(text) {
|
|
116
|
+
found[index] = true;
|
|
117
|
+
if starts_with_word(rest) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
accepted = Some(found);
|
|
121
|
+
let Some(next) = skip_space(rest).strip_prefix(b",") else {
|
|
122
|
+
break;
|
|
123
|
+
};
|
|
124
|
+
text = skip_space(next);
|
|
125
|
+
}
|
|
126
|
+
accepted
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fn criterion(text: &[u8]) -> Option<(usize, &[u8])> {
|
|
130
|
+
[&b"line"[..], b"branch", b"method"]
|
|
131
|
+
.iter()
|
|
132
|
+
.enumerate()
|
|
133
|
+
.find_map(|(index, name)| text.strip_prefix(*name).map(|rest| (index, rest)))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// What Ruby's `String#lstrip` strips.
|
|
137
|
+
const fn ruby_lstrip_space(byte: u8) -> bool {
|
|
138
|
+
byte == 0 || ruby_space(byte)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Not at a word boundary: Ruby's `\w` comes next.
|
|
142
|
+
fn starts_with_word(text: &[u8]) -> bool {
|
|
143
|
+
text.first().is_some_and(|&byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
|
144
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
fn render(pretty: bool, write: impl FnOnce(&mut Json<'_, Vec<u8>>) -> io::Result<()>) -> String {
|
|
4
|
+
let mut out = Vec::new();
|
|
5
|
+
let mut json = if pretty { Json::pretty(&mut out) } else { Json::compact(&mut out) };
|
|
6
|
+
write(&mut json).unwrap();
|
|
7
|
+
String::from_utf8(out).unwrap()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Ruby's output for `{a: [], b: {}, c: [1, {d: nil}]}`.
|
|
11
|
+
#[test]
|
|
12
|
+
fn layouts_of_pretty_generate_and_generate() {
|
|
13
|
+
let document = |json: &mut Json<'_, Vec<u8>>| {
|
|
14
|
+
json.object(|json| {
|
|
15
|
+
json.key("a")?.array(|_| Ok(()))?;
|
|
16
|
+
json.key("b")?.object(|_| Ok(()))?;
|
|
17
|
+
json.key("c")?.array(|json| {
|
|
18
|
+
json.item()?.uint(1)?;
|
|
19
|
+
json.item()?.object(|json| json.key("d")?.null())
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
};
|
|
23
|
+
assert_eq!(
|
|
24
|
+
render(true, document),
|
|
25
|
+
"{\n \"a\": [],\n \"b\": {},\n \"c\": [\n 1,\n {\n \"d\": null\n }\n ]\n}"
|
|
26
|
+
);
|
|
27
|
+
assert_eq!(render(false, document), r#"{"a":[],"b":{},"c":[1,{"d":null}]}"#);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Ruby's `JSON.generate(["\x1f\x01\b\f\n\r\t\"\\/é ", 100.0, 66.66666666666667, 0.1 + 0.2])`.
|
|
31
|
+
#[test]
|
|
32
|
+
fn strings_and_floats_as_ruby_writes_them() {
|
|
33
|
+
let rendered = render(false, |json| {
|
|
34
|
+
json.array(|json| {
|
|
35
|
+
json.item()?.str("\u{1f}\u{1}\u{8}\u{c}\n\r\t\"\\/é ")?;
|
|
36
|
+
json.item()?.float(100.0)?;
|
|
37
|
+
json.item()?.float(200.0 / 3.0)?;
|
|
38
|
+
json.item()?.float(0.1 + 0.2)?;
|
|
39
|
+
json.item()?.bool(false)
|
|
40
|
+
})
|
|
41
|
+
});
|
|
42
|
+
assert_eq!(
|
|
43
|
+
rendered,
|
|
44
|
+
r#"["\u001f\u0001\b\f\n\r\t\"\\/é ",100.0,66.66666666666667,0.30000000000000004,false]"#
|
|
45
|
+
);
|
|
46
|
+
}
|