padded_cell 0.3.0 → 0.4.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 +12 -0
- data/Cargo.lock +2 -2
- data/README.md +33 -0
- data/ext/padded_cell_native/core/Cargo.toml +1 -1
- data/ext/padded_cell_native/core/src/chart/source.rs +12 -3
- data/ext/padded_cell_native/ffi/Cargo.toml +1 -1
- data/lib/padded_cell/cell.rb +1 -1
- data/lib/padded_cell/chart/accumulator.rb +27 -9
- data/lib/padded_cell/chart/config.rb +6 -0
- data/lib/padded_cell/chart/delta.rb +13 -0
- data/lib/padded_cell/chart/probe.rb +30 -4
- data/lib/padded_cell/chart/source_files.rb +1 -6
- data/lib/padded_cell/cli.rb +18 -3
- data/lib/padded_cell/reporters.rb +46 -0
- data/lib/padded_cell/results.rb +3 -2
- data/lib/padded_cell/runner.rb +12 -3
- data/lib/padded_cell/version.rb +1 -1
- data/lib/padded_cell/worker.rb +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c460bc9c30d1aceca5e6e4dd764503b7871971c2da3c8aaa7e497581717f92f
|
|
4
|
+
data.tar.gz: f72d0d43ff6ba9fe4505bc2c1293fefc073c89c905b0ae8fa4f8fb27784d597d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c72c3a035714f8c70cb2a93461cde0c447e2417f954af98e23dc689e3b375bda3bd1132527fe0110b2e964ac33c45999e975146d214f7e71230e96e2eb383818
|
|
7
|
+
data.tar.gz: fb6a39de58a92cf0abb8fc3bedfb8f8d72fa3fa14179508e27d416c259e77c73fda818daee6d06ea7f83f95b72432776c1a63a8ba0d692cbb82df73454e28371
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026-11-08)
|
|
4
|
+
|
|
5
|
+
- `PADDED_CELL_ASYLUM`: retries failures from the environment. N retries,
|
|
6
|
+
or `true`/`yes`/`on` for the default 2; `0`, `false`, `no` and `off` leave
|
|
7
|
+
it off. `--asylum` still wins.
|
|
8
|
+
- `--coverage-map PATH` (or `PADDED_CELL_COVERAGE_MAP`): the lines each test
|
|
9
|
+
ran, as JSON lines (`{"id":…,"file":…,"line":…,"covers":{path => lines}}`),
|
|
10
|
+
for CI that reruns only the tests a diff touches. Turns on
|
|
11
|
+
`--coverage=lines,methods` when coverage is off; never changes the summed
|
|
12
|
+
report. Free with `--isolation test`; with `--isolation worker`, two
|
|
13
|
+
coverage copies per test.
|
|
14
|
+
|
|
3
15
|
## 0.3.0 (2026-11-08)
|
|
4
16
|
|
|
5
17
|
- `--budget SECONDS`: a deadline for the whole run, loading included. Once
|
data/Cargo.lock
CHANGED
|
@@ -226,7 +226,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
226
226
|
|
|
227
227
|
[[package]]
|
|
228
228
|
name = "padded_cell"
|
|
229
|
-
version = "0.
|
|
229
|
+
version = "0.4.0"
|
|
230
230
|
dependencies = [
|
|
231
231
|
"ruby-prism",
|
|
232
232
|
"supervisor-machines",
|
|
@@ -236,7 +236,7 @@ dependencies = [
|
|
|
236
236
|
|
|
237
237
|
[[package]]
|
|
238
238
|
name = "padded_cell_native"
|
|
239
|
-
version = "0.
|
|
239
|
+
version = "0.4.0"
|
|
240
240
|
dependencies = [
|
|
241
241
|
"deadline-machines",
|
|
242
242
|
"magnus",
|
data/README.md
CHANGED
|
@@ -72,6 +72,7 @@ $ padded_cell --timeout 30 # kill and fail anything running 30s
|
|
|
72
72
|
$ padded_cell --budget 600 # the whole run gets 10 minutes; what's left out fails it
|
|
73
73
|
$ padded_cell --fail-fast
|
|
74
74
|
$ padded_cell --asylum # retry failures; report the flaky ones (see below)
|
|
75
|
+
$ PADDED_CELL_ASYLUM=2 padded_cell # same, from a CI runner's env
|
|
75
76
|
$ padded_cell --list # what would run, without loading anything
|
|
76
77
|
$ padded_cell --shard 2/4 # CI: the second quarter of the classes
|
|
77
78
|
$ padded_cell --junit tmp/junit.xml --json tmp/results.jsonl
|
|
@@ -271,6 +272,38 @@ The merge refuses files that differ between shards or from the checkout,
|
|
|
271
272
|
and checks thresholds when every shard of the run is there. Lines that run
|
|
272
273
|
while the suite loads count once per shard.
|
|
273
274
|
|
|
275
|
+
### Coverage map
|
|
276
|
+
|
|
277
|
+
Which lines each test ran, so CI can rerun only the tests touching a diff:
|
|
278
|
+
|
|
279
|
+
```console
|
|
280
|
+
$ padded_cell --coverage-map tmp/coverage_map.jsonl
|
|
281
|
+
$ PADDED_CELL_COVERAGE_MAP=tmp/coverage_map.jsonl padded_cell # same; the flag wins
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
One JSON object per test, in id order, written at the end of the run:
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{"id":"SignTest#test_zero","file":"test/calc_test.rb","line":28,"covers":{"lib/calc.rb":[[7,8],10,13]}}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`covers` holds the files the coverage report shows (filters apply, `test/`
|
|
291
|
+
stays out), each with its line numbers ascending, `[first, last]` for a run
|
|
292
|
+
of consecutive ones. Lines a test runs while loading a file count for it.
|
|
293
|
+
|
|
294
|
+
Ruby's line coverage never sees a call to `def add(a, b) = a + b`: with
|
|
295
|
+
`methods` measured, every method a test called adds its def line. Without
|
|
296
|
+
`--coverage`, the map turns on `lines,methods`. With it, the map adds no
|
|
297
|
+
criterion, so the report is byte for byte the same as without the map:
|
|
298
|
+
include `methods`, or one-liners go unmapped.
|
|
299
|
+
|
|
300
|
+
First attempts only, like the report. A test whose cell or worker died is
|
|
301
|
+
left out: it was not measured, so a selector should run it anyway.
|
|
302
|
+
|
|
303
|
+
A cell's measure is already its test's: free under `--isolation test`. With
|
|
304
|
+
`--isolation worker`, Ruby copies the coverage of every loaded file twice per
|
|
305
|
+
test (about 24 ms with 800 files loaded, more in a big Rails app).
|
|
306
|
+
|
|
274
307
|
### Cost
|
|
275
308
|
|
|
276
309
|
Measuring is opt-in: Ruby's instrumentation slows tests down. On a Rails
|
|
@@ -51,15 +51,24 @@ impl Source {
|
|
|
51
51
|
/// The 1-based line and the 0-based byte column of `offset`.
|
|
52
52
|
#[must_use]
|
|
53
53
|
pub fn position(&self, offset: usize) -> (u32, u32) {
|
|
54
|
-
let line = self.
|
|
54
|
+
let line = self.line_index(offset);
|
|
55
55
|
let number = |value: usize| u32::try_from(value).unwrap_or(u32::MAX);
|
|
56
|
-
(number(line.strict_add(1)), number(offset.strict_sub(self.
|
|
56
|
+
(number(line.strict_add(1)), number(offset.strict_sub(self.start_of(line))))
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/// Where the line holding `offset` starts.
|
|
60
60
|
#[must_use]
|
|
61
61
|
pub fn line_start(&self, offset: usize) -> usize {
|
|
62
|
-
|
|
62
|
+
self.start_of(self.line_index(offset))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// The 0-based line holding `offset`.
|
|
66
|
+
fn line_index(&self, offset: usize) -> usize {
|
|
67
|
+
self.ends.partition_point(|&end| end <= offset)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Where the 0-based `line` starts.
|
|
71
|
+
fn start_of(&self, line: usize) -> usize {
|
|
63
72
|
line.checked_sub(1).map_or(0, |previous| self.ends[previous])
|
|
64
73
|
}
|
|
65
74
|
|
data/lib/padded_cell/cell.rb
CHANGED
|
@@ -24,7 +24,7 @@ module PaddedCell
|
|
|
24
24
|
pid = fork_cell(klass, name, seed, reader, writer, close, probe)
|
|
25
25
|
writer.close
|
|
26
26
|
delta, result = collect(pid, reader, klass, name, started, deadline) { limits.message(deadline) }
|
|
27
|
-
delta ? probe.absorb(delta) : probe&.lost
|
|
27
|
+
delta ? probe.absorb(delta, result) : probe&.lost
|
|
28
28
|
result
|
|
29
29
|
ensure
|
|
30
30
|
reader&.close
|
|
@@ -13,11 +13,14 @@ module PaddedCell
|
|
|
13
13
|
attr_reader :criteria
|
|
14
14
|
|
|
15
15
|
# criteria: what Ruby's Coverage measures, :lines always among them
|
|
16
|
-
|
|
16
|
+
# map: each test's lines go on its result too (--coverage-map)
|
|
17
|
+
def initialize(criteria, map: false)
|
|
17
18
|
@criteria = criteria
|
|
19
|
+
@map = map
|
|
18
20
|
@root = File.realpath(Dir.pwd)
|
|
19
21
|
@roots = [Dir.pwd, @root].uniq.map { "#{it}/" }
|
|
20
22
|
@kept = {}
|
|
23
|
+
@names = {}
|
|
21
24
|
@lost = 0
|
|
22
25
|
@known = Set.new
|
|
23
26
|
end
|
|
@@ -39,7 +42,16 @@ module PaddedCell
|
|
|
39
42
|
# For a worker, right after its fork.
|
|
40
43
|
def probe
|
|
41
44
|
Probe.new(file: File.join(@frames, "#{Process.pid}.cov"), known: @known, keep: method(:keep?),
|
|
42
|
-
methods: @criteria.include?(:methods), interval: Chart.config.interval)
|
|
45
|
+
methods: @criteria.include?(:methods), interval: Chart.config.interval, map: @map)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A measured file's name in the report, or nil for a file the report
|
|
49
|
+
# leaves out (filtered, outside the root, deleted).
|
|
50
|
+
def project_file(path)
|
|
51
|
+
@names.fetch(path) do
|
|
52
|
+
name = relative(path)
|
|
53
|
+
@names[path] = (name if name && !Chart.config.skipped?(path, name))
|
|
54
|
+
end
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
# A worker died: what it measured since its last flush is gone.
|
|
@@ -85,19 +97,25 @@ module PaddedCell
|
|
|
85
97
|
end
|
|
86
98
|
end
|
|
87
99
|
|
|
88
|
-
# Keys by real path relative to the root
|
|
89
|
-
# symlink and through its real path is one file. A file deleted during
|
|
90
|
-
# the run is not reported.
|
|
100
|
+
# Keys by real path relative to the root.
|
|
91
101
|
def normalize(total)
|
|
92
102
|
total.each_with_object({}) do |(path, entry), files|
|
|
93
|
-
relative =
|
|
94
|
-
next
|
|
103
|
+
relative = relative(path)
|
|
104
|
+
next unless relative
|
|
95
105
|
|
|
96
106
|
files[relative] ? Delta.merge!(files, { relative => entry }) : files[relative] = entry
|
|
97
|
-
rescue SystemCallError
|
|
98
|
-
next
|
|
99
107
|
end
|
|
100
108
|
end
|
|
109
|
+
|
|
110
|
+
# By real path: a file required through a symlink and through its real
|
|
111
|
+
# path is one file. nil outside the root, or for a file deleted during
|
|
112
|
+
# the run.
|
|
113
|
+
def relative(path)
|
|
114
|
+
relative = File.realpath(path).delete_prefix("#{@root}/")
|
|
115
|
+
relative unless relative.start_with?('/')
|
|
116
|
+
rescue SystemCallError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
101
119
|
end
|
|
102
120
|
end
|
|
103
121
|
end
|
|
@@ -65,6 +65,12 @@ module PaddedCell
|
|
|
65
65
|
end
|
|
66
66
|
alias filter skip
|
|
67
67
|
|
|
68
|
+
# Whether a filter leaves the file out (path absolute, name relative).
|
|
69
|
+
def skipped?(path, name)
|
|
70
|
+
file = SourceFile.new(path, name)
|
|
71
|
+
@filters.any? { it.call(file) }
|
|
72
|
+
end
|
|
73
|
+
|
|
68
74
|
# Removes every filter, the defaults included.
|
|
69
75
|
def clear_filters = @filters.clear
|
|
70
76
|
|
|
@@ -51,6 +51,19 @@ module PaddedCell
|
|
|
51
51
|
total
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# {path => [line number, ...]}: the lines that ran, ascending, by file.
|
|
55
|
+
# A call to `def add(a, b) = a + b` runs no line Ruby sees: methods,
|
|
56
|
+
# when measured, add the first line of every method that ran.
|
|
57
|
+
def ran(delta)
|
|
58
|
+
delta.each_with_object({}) do |(path, entry), ran|
|
|
59
|
+
lines = entry[:lines]
|
|
60
|
+
indices = lines.is_a?(Hash) ? lines.keys : lines.each_index.select { lines[it]&.positive? }
|
|
61
|
+
numbers = indices.map { it + 1 }
|
|
62
|
+
entry[:methods]&.each { |(_, first), (_, hits)| numbers << first if hits.positive? }
|
|
63
|
+
ran[path] = numbers.uniq.sort unless numbers.empty?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
54
67
|
# Coverage's arrays are frozen.
|
|
55
68
|
def dense(data)
|
|
56
69
|
{
|
|
@@ -16,15 +16,22 @@ module PaddedCell
|
|
|
16
16
|
# A flush copies the coverage of every file Ruby loaded, gems included: a
|
|
17
17
|
# few milliseconds in a small app, a few hundred in a big Rails one. The
|
|
18
18
|
# interval stretches to keep flushes under 1% of a worker's time.
|
|
19
|
+
#
|
|
20
|
+
# With the map on (--coverage-map), each test's result also carries the
|
|
21
|
+
# lines it ran, in its metadata under :covers. A cell's delta is already
|
|
22
|
+
# its test's; a worker running tests itself clears Coverage around each
|
|
23
|
+
# one, two more copies per test. What it clears goes into its measure, so
|
|
24
|
+
# the sum does not change.
|
|
19
25
|
class Probe
|
|
20
26
|
INTERVAL = 10.0
|
|
21
27
|
BUDGET = 100
|
|
22
28
|
|
|
23
|
-
def initialize(file:, known:, keep:, methods:, interval: INTERVAL)
|
|
29
|
+
def initialize(file:, known:, keep:, methods:, interval: INTERVAL, map: false)
|
|
24
30
|
@file = file
|
|
25
31
|
@known = known
|
|
26
32
|
@keep = keep
|
|
27
33
|
@methods = methods
|
|
34
|
+
@map = map
|
|
28
35
|
@interval = interval
|
|
29
36
|
@wait = interval
|
|
30
37
|
@pending = {}
|
|
@@ -40,8 +47,22 @@ module PaddedCell
|
|
|
40
47
|
# The delta of a cell, at its end.
|
|
41
48
|
def capture = Delta.capture(::Coverage.peek_result, @known, @keep)
|
|
42
49
|
|
|
43
|
-
# A cell's delta, in its worker
|
|
44
|
-
|
|
50
|
+
# A cell's delta, in its worker; with the map on, result gets the lines
|
|
51
|
+
# its test ran.
|
|
52
|
+
def absorb(delta, result = nil)
|
|
53
|
+
result.metadata[:covers] = Delta.ran(delta) if @map && result
|
|
54
|
+
Delta.merge!(@pending, delta)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Runs a test in the worker (isolation :worker), which yields its result.
|
|
58
|
+
def measure
|
|
59
|
+
return yield unless @map
|
|
60
|
+
|
|
61
|
+
absorb(clear)
|
|
62
|
+
result = yield
|
|
63
|
+
absorb(clear, result)
|
|
64
|
+
result
|
|
65
|
+
end
|
|
45
66
|
|
|
46
67
|
# A cell that died before reporting.
|
|
47
68
|
def lost = @lost += 1
|
|
@@ -53,7 +74,7 @@ module PaddedCell
|
|
|
53
74
|
|
|
54
75
|
def flush
|
|
55
76
|
started = Minitest.clock_time
|
|
56
|
-
delta = Delta.merge!(
|
|
77
|
+
delta = Delta.merge!(clear, @pending)
|
|
57
78
|
frame = Marshal.dump({ delta:, lost: @lost })
|
|
58
79
|
File.open(@file, 'ab') { it.write([frame.bytesize].pack('N') + frame) }
|
|
59
80
|
# Dense entries brought their structure to the runner: from now on,
|
|
@@ -65,6 +86,11 @@ module PaddedCell
|
|
|
65
86
|
# 0: after every test, whatever it costs.
|
|
66
87
|
@wait = @interval.zero? ? 0 : [@interval, (@flushed - started) * BUDGET].max
|
|
67
88
|
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# What ran since the last clear, taken out of Coverage.
|
|
93
|
+
def clear = Delta.capture(::Coverage.result(stop: false, clear: true), @known, @keep)
|
|
68
94
|
end
|
|
69
95
|
end
|
|
70
96
|
end
|
|
@@ -19,7 +19,7 @@ module PaddedCell
|
|
|
19
19
|
loaded = loaded_features
|
|
20
20
|
never, @unmeasured = tracked(files).partition { !loaded.include?(File.join(root, it)) }
|
|
21
21
|
@rows = (files.map { |name, entry| row(name, entry) } + stubs(never))
|
|
22
|
-
.reject { |path, name| skipped?(path, name) }
|
|
22
|
+
.reject { |path, name| @config.skipped?(path, name) }
|
|
23
23
|
.sort_by { it[1] }
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -72,11 +72,6 @@ module PaddedCell
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
def skipped?(path, name)
|
|
76
|
-
file = SourceFile.new(path, name)
|
|
77
|
-
@config.filters.any? { it.call(file) }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
75
|
# Required files, also under the root's real path.
|
|
81
76
|
def loaded_features
|
|
82
77
|
pwd = "#{Dir.pwd}/"
|
data/lib/padded_cell/cli.rb
CHANGED
|
@@ -22,7 +22,8 @@ module PaddedCell
|
|
|
22
22
|
# Exit status: 0 passed, 1 failed, 2 usage or load error, 3 coverage below
|
|
23
23
|
# a minimum, 130 interrupted.
|
|
24
24
|
def start(argv, io: $stdout)
|
|
25
|
-
options = { io:, coverage: coverage_criteria(ENV.fetch('PADDED_CELL_COVERAGE', nil)), **report_paths
|
|
25
|
+
options = { io:, coverage: coverage_criteria(ENV.fetch('PADDED_CELL_COVERAGE', nil)), **report_paths,
|
|
26
|
+
**asylum_from_env }
|
|
26
27
|
parser(options).parse!(argv)
|
|
27
28
|
return merge(options) if options[:coverage_merge]
|
|
28
29
|
|
|
@@ -34,12 +35,22 @@ module PaddedCell
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# Report paths a CI runner can set without touching the command line;
|
|
37
|
-
# --json and --
|
|
38
|
+
# --json, --junit and --coverage-map still win.
|
|
38
39
|
def report_paths
|
|
39
|
-
{ json: ENV.fetch('PADDED_CELL_JSON', nil), junit: ENV.fetch('PADDED_CELL_JUNIT', nil)
|
|
40
|
+
{ json: ENV.fetch('PADDED_CELL_JSON', nil), junit: ENV.fetch('PADDED_CELL_JUNIT', nil),
|
|
41
|
+
coverage_map: ENV.fetch('PADDED_CELL_COVERAGE_MAP', nil) }
|
|
40
42
|
.reject { |_, path| path.nil? || path.empty? }
|
|
41
43
|
end
|
|
42
44
|
|
|
45
|
+
# PADDED_CELL_ASYLUM=N, or 1/true for the default 2 retries; --asylum wins.
|
|
46
|
+
def asylum_from_env
|
|
47
|
+
value = ENV.fetch('PADDED_CELL_ASYLUM', '').strip
|
|
48
|
+
return {} if value.empty? || %w[0 false no off].include?(value.downcase)
|
|
49
|
+
return { asylum: 2 } if %w[true yes on].include?(value.downcase)
|
|
50
|
+
|
|
51
|
+
{ asylum: positive(Integer(value, 10)) }
|
|
52
|
+
end
|
|
53
|
+
|
|
43
54
|
def parser(options)
|
|
44
55
|
OptionParser.new do |opts|
|
|
45
56
|
opts.banner = BANNER
|
|
@@ -98,6 +109,10 @@ module PaddedCell
|
|
|
98
109
|
opts.on('--coverage-dir DIR', 'Write coverage reports to DIR (default: coverage, or the test helper\'s)') do
|
|
99
110
|
options[:coverage_dir] = it
|
|
100
111
|
end
|
|
112
|
+
opts.on('--coverage-map PATH', 'Write the lines each test ran to PATH, as JSON lines (measures ' \
|
|
113
|
+
'lines,methods if coverage is off; or $PADDED_CELL_COVERAGE_MAP)') do
|
|
114
|
+
options[:coverage_map] = it
|
|
115
|
+
end
|
|
101
116
|
opts.on('--coverage-merge DIR,DIR', Array, 'Merge the coverage of shards (their --coverage-dir) ' \
|
|
102
117
|
'into one report; runs no test') do
|
|
103
118
|
options[:coverage_merge] = it
|
|
@@ -103,6 +103,52 @@ module PaddedCell
|
|
|
103
103
|
def escape(text) = text.to_s.scrub.gsub(INVALID, '').gsub(/[&<>"']/, ESCAPES)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# --coverage-map: per test, the lines of the project it ran, one JSON
|
|
107
|
+
# object per line, in id order:
|
|
108
|
+
#
|
|
109
|
+
# {"id":"SignTest#test_zero","file":"test/calc_test.rb","line":28,
|
|
110
|
+
# "covers":{"lib/calc.rb":[[7,8],10,13]}}
|
|
111
|
+
#
|
|
112
|
+
# Line numbers ascend; [first, last] stands for a run of consecutive
|
|
113
|
+
# ones. Files are those the coverage report would show. A test that was
|
|
114
|
+
# not measured (its cell or worker died) is left out.
|
|
115
|
+
class CoverageMap < FileReporter
|
|
116
|
+
# coverage: the run's Chart::Accumulator
|
|
117
|
+
def initialize(path, coverage:)
|
|
118
|
+
super(path)
|
|
119
|
+
@coverage = coverage
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A first attempt, as it arrives: what it ran comes off its metadata.
|
|
123
|
+
def record(result)
|
|
124
|
+
covers = result.metadata? && result.metadata.delete(:covers)
|
|
125
|
+
@results << entry(result, covers) if covers
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def render(entries) = entries.sort_by { it[:id] }.map { "#{JSON.generate(it)}\n" }.join
|
|
131
|
+
|
|
132
|
+
def entry(result, covers)
|
|
133
|
+
file, line = file_and_line(result)
|
|
134
|
+
{ id: "#{result.klass}##{result.name}", file:, line:, covers: files(covers) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Two paths can name one file (a symlink): their lines are merged.
|
|
138
|
+
def files(covers)
|
|
139
|
+
files = Hash.new { |hash, name| hash[name] = [] }
|
|
140
|
+
covers.each do |path, lines|
|
|
141
|
+
name = @coverage.project_file(path)
|
|
142
|
+
files[name].concat(lines) if name
|
|
143
|
+
end
|
|
144
|
+
files.sort.to_h { |name, lines| [name, ranges(lines.uniq.sort)] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def ranges(lines)
|
|
148
|
+
lines.slice_when { |previous, line| line != previous + 1 }.map { it.size == 1 ? it[0] : [it[0], it[-1]] }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
106
152
|
# One JSON object per test, one per line.
|
|
107
153
|
class JSONLines < FileReporter
|
|
108
154
|
private
|
data/lib/padded_cell/results.rb
CHANGED
|
@@ -88,12 +88,13 @@ module PaddedCell
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# Marshal.dump, after replacing failures that cannot cross a pipe (an
|
|
91
|
-
# exception holding a Proc, an IO...) with plain copies.
|
|
91
|
+
# exception holding a Proc, an IO...) with plain copies. Of the metadata,
|
|
92
|
+
# only the coverage map's plain data survives.
|
|
92
93
|
def dump(result)
|
|
93
94
|
Marshal.dump(result)
|
|
94
95
|
rescue TypeError, ArgumentError
|
|
95
96
|
result.failures = result.failures.map { portable(it) }
|
|
96
|
-
result.metadata =
|
|
97
|
+
result.metadata = result.metadata.slice(:covers) if result.metadata?
|
|
97
98
|
Marshal.dump(result)
|
|
98
99
|
end
|
|
99
100
|
|
data/lib/padded_cell/runner.rb
CHANGED
|
@@ -23,7 +23,7 @@ module PaddedCell
|
|
|
23
23
|
paths: [], jobs: Etc.nprocessors, seed: nil, include: nil, exclude: nil, shard: [0, 1],
|
|
24
24
|
isolation: nil, timeout: nil, budget: nil, asylum: nil, fail_fast: false, verbose: false, show_skips: false,
|
|
25
25
|
list: false, junit: nil, json: nil, timings: 'tmp/padded_cell/timings.tsv', coverage: nil,
|
|
26
|
-
coverage_dir: nil, io: $stdout
|
|
26
|
+
coverage_dir: nil, coverage_map: nil, io: $stdout
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
29
|
attr_reader :options
|
|
@@ -31,6 +31,9 @@ module PaddedCell
|
|
|
31
31
|
def initialize(**options)
|
|
32
32
|
@options = DEFAULTS.merge(options)
|
|
33
33
|
@options[:seed] ||= (ENV['SEED'] || Random.new_seed).to_i % 0xFFFF
|
|
34
|
+
# The map is made of lines, and of methods: a call to a one-line
|
|
35
|
+
# method runs no line Ruby sees.
|
|
36
|
+
@options[:coverage] ||= %i[lines methods] if @options[:coverage_map]
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
# Exit status: 0 when everything passed, 1 otherwise, 3 when only
|
|
@@ -62,7 +65,7 @@ module PaddedCell
|
|
|
62
65
|
# Before the suite loads: nothing loaded earlier is measured.
|
|
63
66
|
def start_coverage
|
|
64
67
|
require_relative 'chart'
|
|
65
|
-
Chart::Accumulator.new(options[:coverage]).start
|
|
68
|
+
Chart::Accumulator.new(options[:coverage], map: !options[:coverage_map].nil?).start
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
# Two tools cannot share Ruby's Coverage.
|
|
@@ -97,10 +100,16 @@ module PaddedCell
|
|
|
97
100
|
nothing_ran = batches.empty?
|
|
98
101
|
reporter = build_reporter
|
|
99
102
|
reporter.start
|
|
103
|
+
# First attempts only, as with the coverage it is made of.
|
|
104
|
+
map = Reporters::CoverageMap.new(options[:coverage_map], coverage: @coverage) if options[:coverage_map]
|
|
100
105
|
retried = []
|
|
101
106
|
@coverage&.snapshot
|
|
102
|
-
status = dispatch(tests, batches, reporter, retried)
|
|
107
|
+
status = dispatch(tests, batches, reporter, retried) do |index, result|
|
|
108
|
+
timings[ids[index]] = result.time
|
|
109
|
+
map&.record(result)
|
|
110
|
+
end
|
|
103
111
|
reporter.report
|
|
112
|
+
map&.report
|
|
104
113
|
report_asylum(retried)
|
|
105
114
|
covered = @coverage ? report_coverage(status, reporter) : true
|
|
106
115
|
Timings.save(options[:timings], timings)
|
data/lib/padded_cell/version.rb
CHANGED
data/lib/padded_cell/worker.rb
CHANGED
|
@@ -55,6 +55,8 @@ module PaddedCell
|
|
|
55
55
|
Results.crashed(klass, name, "after_fork hook failed: #{setup_error.class}: #{setup_error.message}")
|
|
56
56
|
elsif @isolation == :test
|
|
57
57
|
Cell.run(klass, name, seed: @seed, limits: @limits, close: [socket], probe: @probe)
|
|
58
|
+
elsif @probe
|
|
59
|
+
@probe.measure { Cell.execute(klass, name) }
|
|
58
60
|
else
|
|
59
61
|
Cell.execute(klass, name)
|
|
60
62
|
end
|