padded_cell 0.1.0 → 0.2.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Cargo.lock +2 -2
  4. data/README.md +148 -7
  5. data/ext/padded_cell_native/core/Cargo.toml +1 -1
  6. data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
  7. data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
  8. data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
  9. data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
  10. data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
  11. data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
  12. data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
  13. data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
  14. data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
  15. data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
  16. data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
  17. data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
  18. data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
  19. data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
  20. data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
  21. data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
  22. data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
  23. data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
  24. data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
  25. data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
  26. data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
  27. data/ext/padded_cell_native/core/src/chart.rs +302 -0
  28. data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
  29. data/ext/padded_cell_native/core/src/discover.rs +7 -53
  30. data/ext/padded_cell_native/core/src/error.rs +24 -0
  31. data/ext/padded_cell_native/core/src/lib.rs +5 -1
  32. data/ext/padded_cell_native/core/src/parallel.rs +43 -0
  33. data/ext/padded_cell_native/ffi/Cargo.toml +1 -1
  34. data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
  35. data/ext/padded_cell_native/ffi/src/lib.rs +3 -0
  36. data/lib/padded_cell/asylum.rb +91 -0
  37. data/lib/padded_cell/cell.rb +14 -8
  38. data/lib/padded_cell/chart/accumulator.rb +103 -0
  39. data/lib/padded_cell/chart/config.rb +129 -0
  40. data/lib/padded_cell/chart/delta.rb +148 -0
  41. data/lib/padded_cell/chart/document.rb +64 -0
  42. data/lib/padded_cell/chart/filter.rb +55 -0
  43. data/lib/padded_cell/chart/merge.rb +63 -0
  44. data/lib/padded_cell/chart/probe.rb +70 -0
  45. data/lib/padded_cell/chart/raw.rb +48 -0
  46. data/lib/padded_cell/chart/report.rb +128 -0
  47. data/lib/padded_cell/chart/source_files.rb +90 -0
  48. data/lib/padded_cell/chart.rb +38 -0
  49. data/lib/padded_cell/cli.rb +59 -4
  50. data/lib/padded_cell/loader.rb +5 -1
  51. data/lib/padded_cell/pool.rb +19 -42
  52. data/lib/padded_cell/reporters.rb +20 -3
  53. data/lib/padded_cell/results.rb +13 -0
  54. data/lib/padded_cell/runner.rb +125 -22
  55. data/lib/padded_cell/version.rb +1 -1
  56. data/lib/padded_cell/worker.rb +63 -0
  57. data/lib/padded_cell.rb +8 -0
  58. metadata +39 -1
@@ -0,0 +1,205 @@
1
+ use std::path::PathBuf;
2
+
3
+ use magnus::{Error, RArray, RHash, Ruby, Symbol, TryConvert, Value};
4
+ use padded_cell::chart::{
5
+ self, Arm, CRITERIA, Condition, Criterion, FileCoverage, Format, Meta, Method, Options, Scope,
6
+ Span, Stats, Summary, Thresholds, Totals,
7
+ };
8
+
9
+ use crate::file_error;
10
+ use padded_cell::FileError;
11
+
12
+ /// Reads every source, writes the reports, checks the thresholds, and
13
+ /// returns what it found:
14
+ ///
15
+ /// ```text
16
+ /// files [[path, name, lines, branches or nil, methods or nil, loaded], ...]
17
+ /// branches [[type, l1, c1, l2, c2, [[type, l1, c1, l2, c2, hits], ...]], ...]
18
+ /// methods [[owner, name, l1, c1, l2, c2, hits], ...]
19
+ /// groups [[name, [file index, ...]], ...]
20
+ /// options {dir:, formats: ["lcov", "json"], nocov_token:, criteria: ["line", "branch"],
21
+ /// minimum:, minimum_by_file:, maximum_drop:, last_run: [line, branch, method] (nil: none),
22
+ /// minimum_by_group: [[name, [line, branch, method]], ...], write_last_run:,
23
+ /// root:, timestamp:, created_at:, commit:, project_name:, generator:}
24
+ ///
25
+ /// => {total: stats, groups: [[name, stats], ...], files: [[name, stats], ...],
26
+ /// violations: [[scope, name or nil, criterion, limit, actual], ...]}
27
+ /// stats {line: [covered, missed, omitted, hits, percent, strength], branch: ..., method: ...}
28
+ /// ```
29
+ pub fn chart_report(
30
+ ruby: &Ruby,
31
+ files: RArray,
32
+ groups: Vec<(String, Vec<usize>)>,
33
+ options: RHash,
34
+ ) -> Result<RHash, Error> {
35
+ let option = |key: &str| ruby.to_symbol(key);
36
+ let formats = options
37
+ .fetch::<_, Vec<String>>(option("formats"))?
38
+ .iter()
39
+ .map(|name| name.parse::<Format>())
40
+ .collect::<Result<Vec<_>, _>>()
41
+ .map_err(|error| Error::new(ruby.exception_arg_error(), error.to_string()))?;
42
+ let measured: Vec<Criterion> = options
43
+ .fetch::<_, Vec<String>>(option("criteria"))?
44
+ .iter()
45
+ .filter_map(|name| CRITERIA.into_iter().find(|criterion| criterion.singular() == name))
46
+ .collect();
47
+ let thresholds = Thresholds {
48
+ minimum: options.fetch(option("minimum"))?,
49
+ minimum_by_file: options.fetch(option("minimum_by_file"))?,
50
+ minimum_by_group: options.fetch(option("minimum_by_group"))?,
51
+ maximum_drop: options.fetch(option("maximum_drop"))?,
52
+ last_run: options.fetch(option("last_run"))?,
53
+ };
54
+ let meta = Meta {
55
+ root: options.fetch(option("root"))?,
56
+ timestamp: options.fetch(option("timestamp"))?,
57
+ created_at: options.fetch(option("created_at"))?,
58
+ commit: options.fetch(option("commit"))?,
59
+ project_name: options.fetch(option("project_name"))?,
60
+ generator: options.fetch(option("generator"))?,
61
+ };
62
+ let dir: PathBuf = options.fetch(option("dir"))?;
63
+ let analysis = Options {
64
+ nocov_token: options.fetch(option("nocov_token"))?,
65
+ methods: measured.contains(&Criterion::Method),
66
+ };
67
+ let files = (0..files.len())
68
+ .map(|index| file(files.entry(index.cast_signed())?))
69
+ .collect::<Result<Vec<_>, Error>>()?;
70
+
71
+ let failed = |error: FileError| file_error(ruby, &error);
72
+ let reports = chart::analyze_all(&files, &analysis).map_err(failed)?;
73
+ let totals = Totals::of_files(&reports);
74
+ let groups: Vec<(String, Vec<usize>, Totals)> = groups
75
+ .into_iter()
76
+ .map(|(name, members)| {
77
+ let totals = Totals::of_files(members.iter().filter_map(|&index| reports.get(index)));
78
+ (name, members, totals)
79
+ })
80
+ .collect();
81
+ let named: Vec<(String, Totals)> =
82
+ groups.iter().map(|(name, _, totals)| (name.clone(), *totals)).collect();
83
+ let violations = chart::violations(&thresholds, &measured, &totals, &reports, &named);
84
+ let summary = Summary { files: &reports, measured, totals, groups, violations, meta };
85
+ for format in formats {
86
+ chart::write(&dir, format, &summary).map_err(failed)?;
87
+ }
88
+ if options.fetch(option("write_last_run"))? {
89
+ chart::write_last_run_file(&dir, &summary).map_err(failed)?;
90
+ }
91
+ result(ruby, &summary)
92
+ }
93
+
94
+ fn result(ruby: &Ruby, summary: &Summary<'_>) -> Result<RHash, Error> {
95
+ let result = ruby.hash_new();
96
+ result.aset(ruby.to_symbol("total"), totals(ruby, &summary.totals)?)?;
97
+ let groups = ruby.ary_new_capa(summary.groups.len());
98
+ for (name, _, group) in &summary.groups {
99
+ groups.push((name.as_str(), totals(ruby, group)?))?;
100
+ }
101
+ result.aset(ruby.to_symbol("groups"), groups)?;
102
+ let files = ruby.ary_new_capa(summary.files.len());
103
+ for report in summary.files {
104
+ let stats = criteria(ruby, |criterion| report.stats.with_percent(criterion))?;
105
+ files.push((report.file.name.as_str(), stats))?;
106
+ }
107
+ result.aset(ruby.to_symbol("files"), files)?;
108
+ let violations = ruby.ary_new_capa(summary.violations.len());
109
+ for violation in &summary.violations {
110
+ let (scope, name) = match &violation.scope {
111
+ Scope::Total => ("total", None),
112
+ Scope::File(name) => ("file", Some(name.as_str())),
113
+ Scope::Group(name) => ("group", Some(name.as_str())),
114
+ Scope::Drop => ("drop", None),
115
+ };
116
+ let criterion = violation.criterion.singular();
117
+ violations.push((
118
+ ruby.to_symbol(scope),
119
+ name,
120
+ ruby.to_symbol(criterion),
121
+ violation.limit,
122
+ violation.actual,
123
+ ))?;
124
+ }
125
+ result.aset(ruby.to_symbol("violations"), violations)?;
126
+ Ok(result)
127
+ }
128
+
129
+ fn totals(ruby: &Ruby, totals: &Totals) -> Result<RHash, Error> {
130
+ criteria(ruby, |criterion| {
131
+ let stats = totals.get(criterion);
132
+ (stats, stats.percent())
133
+ })
134
+ }
135
+
136
+ fn criteria(ruby: &Ruby, stats: impl Fn(Criterion) -> (Stats, f64)) -> Result<RHash, Error> {
137
+ let hash = ruby.hash_new();
138
+ for criterion in CRITERIA {
139
+ let (stats, percent) = stats(criterion);
140
+ let row =
141
+ (stats.covered, stats.missed, stats.omitted, stats.hits, percent, stats.strength());
142
+ hash.aset(ruby.to_symbol(criterion.singular()), row)?;
143
+ }
144
+ Ok(hash)
145
+ }
146
+
147
+ fn file(row: RArray) -> Result<FileCoverage, Error> {
148
+ Ok(FileCoverage {
149
+ path: row.entry(0)?,
150
+ name: row.entry(1)?,
151
+ lines: row.entry::<RArray>(2)?.to_vec()?,
152
+ branches: row.entry::<Option<RArray>>(3)?.map(conditions).transpose()?,
153
+ methods: row.entry::<Option<RArray>>(4)?.map(methods).transpose()?,
154
+ loaded: row.entry(5)?,
155
+ })
156
+ }
157
+
158
+ fn conditions(rows: RArray) -> Result<Vec<Condition>, Error> {
159
+ rows.into_iter()
160
+ .map(|row| {
161
+ let row = RArray::try_convert(row)?;
162
+ let arms = RArray::try_convert(row.entry(5)?)?
163
+ .into_iter()
164
+ .map(|arm| {
165
+ let arm = RArray::try_convert(arm)?;
166
+ Ok(Arm { kind: name(arm.entry(0)?)?, span: span(arm, 1)?, hits: arm.entry(5)? })
167
+ })
168
+ .collect::<Result<_, Error>>()?;
169
+ Ok(Condition { kind: name(row.entry(0)?)?, span: span(row, 1)?, arms })
170
+ })
171
+ .collect()
172
+ }
173
+
174
+ fn methods(rows: RArray) -> Result<Vec<Method>, Error> {
175
+ rows.into_iter()
176
+ .map(|row| {
177
+ let row = RArray::try_convert(row)?;
178
+ Ok(Method {
179
+ owner: row.entry(0)?,
180
+ name: name(row.entry(1)?)?,
181
+ span: span(row, 2)?,
182
+ hits: row.entry(6)?,
183
+ })
184
+ })
185
+ .collect()
186
+ }
187
+
188
+ /// Four integers from `at`: first line, first column, last line, last column.
189
+ fn span(row: RArray, at: isize) -> Result<Span, Error> {
190
+ Ok(Span {
191
+ start_line: row.entry(at)?,
192
+ start_column: row.entry(at.strict_add(1))?,
193
+ end_line: row.entry(at.strict_add(2))?,
194
+ end_column: row.entry(at.strict_add(3))?,
195
+ })
196
+ }
197
+
198
+ /// A branch type or method name: a Symbol from Ruby's Coverage, a String
199
+ /// once through JSON.
200
+ fn name(value: Value) -> Result<String, Error> {
201
+ match Symbol::from_value(value) {
202
+ Some(symbol) => Ok(symbol.name()?.into_owned()),
203
+ None => String::try_convert(value),
204
+ }
205
+ }
@@ -18,6 +18,8 @@ use magnus::prelude::*;
18
18
  use magnus::{Error, RArray, RString, Ruby, function};
19
19
  use padded_cell::{Discovery, FileError};
20
20
 
21
+ mod chart;
22
+
21
23
  /// `Errno::*` for the path, as Ruby's own file reading raises.
22
24
  fn file_error(ruby: &Ruby, error: &FileError) -> Error {
23
25
  let path = error.path.display().to_string();
@@ -116,5 +118,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
116
118
  module.define_module_function("discover", function!(discover, 1))?;
117
119
  module.define_module_function("discover_files", function!(discover_files, 1))?;
118
120
  module.define_module_function("plan", function!(plan, 5))?;
121
+ module.define_module_function("chart_report", function!(chart::chart_report, 3))?;
119
122
  Ok(())
120
123
  }
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ # --asylum: tests that failed get more attempts once the run is over, one
5
+ # at a time, each in a fresh cell. A test that passes on retry counts as
6
+ # passed but is reported as flaky, with every attempt and what changed:
7
+ #
8
+ # attempt 1, same seed, run alone: timing, contention, or state left by
9
+ # the tests that ran around it
10
+ # attempt 2+, another seed: randomness or test order
11
+ #
12
+ # A test that fails every attempt keeps its first failure.
13
+ class Asylum
14
+ ALONE = 'timing, contention or state left by other tests'
15
+ RESEEDED = 'randomness or test order'
16
+
17
+ # The seed of retry attempt, derived from the run's seed so a report can
18
+ # be replayed. Attempt 1 keeps the run's seed.
19
+ def self.retry_seed(seed, attempt)
20
+ attempt == 1 ? seed : (seed + (attempt * 0x9e37)) % 0xffff
21
+ end
22
+
23
+ def initialize(tests, attempts:, seed:, timeout: nil)
24
+ @tests = tests
25
+ @attempts = attempts
26
+ @seed = seed
27
+ @timeout = timeout
28
+ @success = true
29
+ end
30
+
31
+ # False when a retry's worker failed (its cleanup hook).
32
+ def success? = @success
33
+
34
+ # failures: {test index => first failing result}. Yields (index, result)
35
+ # once per test, with its final result. Its metadata holds the attempts:
36
+ # :flaky when it passed on retry, :asylum when it never did. Both are
37
+ # plain data, for Marshal and the JUnit and JSON reports.
38
+ def call(failures)
39
+ history = failures.to_h { |index, result| [index, [attempt_entry(@seed, false, result)]] }
40
+ pending = failures.keys
41
+ 1.upto(@attempts) do |attempt|
42
+ break if pending.empty?
43
+
44
+ pending -= retry_round(pending, history, attempt) do |index, result|
45
+ result.metadata[:flaky] = flaky(failures[index], history[index], attempt)
46
+ yield index, result
47
+ end
48
+ end
49
+ pending.each do |index|
50
+ failures[index].metadata[:asylum] = { attempts: history[index].size, history: history[index] }
51
+ yield index, failures[index]
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # Runs the pending tests once more, alone; returns the ones that passed.
58
+ def retry_round(pending, history, attempt)
59
+ seed = self.class.retry_seed(@seed, attempt)
60
+ passed = []
61
+ pool = Pool.new(@tests, jobs: 1, isolation: :test, seed:, timeout: @timeout)
62
+ pool.run([pending.dup]) do |index, result|
63
+ recovered = result.passed? && !result.skipped?
64
+ history[index] << attempt_entry(seed, true, recovered ? nil : result)
65
+ next unless recovered
66
+
67
+ passed << index
68
+ yield index, result
69
+ end
70
+ @success &&= pool.success?
71
+ passed
72
+ end
73
+
74
+ # One attempt: its seed, whether it ran alone, and how it ended.
75
+ def attempt_entry(seed, alone, failed)
76
+ { seed:, alone:, outcome: failed ? summary(failed.failure)[:message] : 'passed' }
77
+ end
78
+
79
+ def flaky(first, history, attempt)
80
+ {
81
+ attempts: history.size, seed: history.last[:seed], cause: attempt == 1 ? ALONE : RESEEDED,
82
+ first_failure: summary(first.failure), history:
83
+ }
84
+ end
85
+
86
+ def summary(failure)
87
+ type = failure.is_a?(Minitest::UnexpectedError) ? failure.error.class.name : failure.class.name
88
+ { type:, message: failure.message.lines.first.to_s.strip }
89
+ end
90
+ end
91
+ end
@@ -12,15 +12,18 @@ module PaddedCell
12
12
  # result when the cell died or ran past timeout seconds.
13
13
  #
14
14
  # close: descriptors the cell must not keep open (the worker's socket)
15
- def run(klass, name, seed:, timeout: nil, close: [])
15
+ # probe: the worker's Chart::Probe, which gets the cell's coverage
16
+ def run(klass, name, seed:, timeout: nil, close: [], probe: nil)
16
17
  # Binary: the result is Marshal data, and with Encoding.default_internal
17
18
  # set (Rails sets it) a text-mode pipe transcodes what is written to it.
18
19
  reader, writer = IO.pipe
19
20
  [reader, writer].each(&:binmode)
20
21
  started = Minitest.clock_time
21
- pid = fork_cell(klass, name, seed, reader, writer, close)
22
+ pid = fork_cell(klass, name, seed, reader, writer, close, probe)
22
23
  writer.close
23
- collect(pid, reader, klass, name, started, timeout)
24
+ delta, result = collect(pid, reader, klass, name, started, timeout)
25
+ delta ? probe.absorb(delta) : probe&.lost
26
+ result
24
27
  ensure
25
28
  reader&.close
26
29
  writer&.close unless writer&.closed?
@@ -34,7 +37,7 @@ module PaddedCell
34
37
  Results.crashed(klass, name, "exit(#{e.status}) called during the test")
35
38
  end
36
39
 
37
- def fork_cell(klass, name, seed, reader, writer, close)
40
+ def fork_cell(klass, name, seed, reader, writer, close, probe)
38
41
  $stdout.flush
39
42
  $stderr.flush
40
43
  Process.fork do
@@ -43,7 +46,9 @@ module PaddedCell
43
46
  reader.close
44
47
  close.each(&:close)
45
48
  srand(seed)
46
- writer.write(Results.dump(execute(klass, name)))
49
+ probe&.start
50
+ result = execute(klass, name)
51
+ writer.write(Results.envelope(probe&.capture, result))
47
52
  status = 0
48
53
  rescue Exception => e # anything: report it, then exit!
49
54
  warn "padded_cell: cell #{klass}##{name} failed to report: #{e.class}: #{e.message}"
@@ -54,19 +59,20 @@ module PaddedCell
54
59
  end
55
60
  end
56
61
 
62
+ # [coverage delta or nil, result]
57
63
  def collect(pid, reader, klass, name, started, timeout)
58
64
  data = read(reader, timeout && (started + timeout))
59
65
  elapsed = Minitest.clock_time - started
60
66
  if data.nil?
61
67
  Process.kill(:KILL, pid)
62
68
  Process.wait(pid)
63
- return Results.crashed(klass, name, "timed out after #{timeout}s", time: elapsed)
69
+ return [nil, Results.crashed(klass, name, "timed out after #{timeout}s", time: elapsed)]
64
70
  end
65
71
 
66
72
  _, status = Process.wait2(pid)
67
- return Results.load(data, klass, name) unless data.empty?
73
+ return Results.unwrap(data, klass, name) unless data.empty?
68
74
 
69
- Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)
75
+ [nil, Results.crashed(klass, name, Results.describe(status, 'cell'), time: elapsed)]
70
76
  end
71
77
 
72
78
  # Everything until EOF, or nil when the deadline passes first.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coverage'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ module PaddedCell
8
+ module Chart
9
+ # The runner's side. Starts Ruby's Coverage before the suite loads, hands
10
+ # each worker a Probe, then sums its own measure (loading the suite) with
11
+ # every frame the workers wrote.
12
+ class Accumulator
13
+ attr_reader :criteria
14
+
15
+ # criteria: what Ruby's Coverage measures, :lines always among them
16
+ def initialize(criteria)
17
+ @criteria = criteria
18
+ @root = File.realpath(Dir.pwd)
19
+ @roots = [Dir.pwd, @root].uniq.map { "#{it}/" }
20
+ @kept = {}
21
+ @lost = 0
22
+ @known = Set.new
23
+ end
24
+
25
+ # Nothing loaded before is measured.
26
+ def start
27
+ ::Coverage.setup(**@criteria.to_h { [it, true] })
28
+ ::Coverage.resume
29
+ @frames = Dir.mktmpdir('padded_cell-coverage')
30
+ self
31
+ end
32
+
33
+ # After the suite loaded, before the first fork: the files whose
34
+ # structure the runner will report itself. Forks inherit the set.
35
+ def snapshot
36
+ ::Coverage.peek_result.each_key { @known << it if keep?(it) }
37
+ end
38
+
39
+ # For a worker, right after its fork.
40
+ def probe
41
+ Probe.new(file: File.join(@frames, "#{Process.pid}.cov"), known: @known, keep: method(:keep?),
42
+ methods: @criteria.include?(:methods), interval: Chart.config.interval)
43
+ end
44
+
45
+ # A worker died: what it measured since its last flush is gone.
46
+ def lost_worker = @lost += 1
47
+
48
+ # Sums everything (see Raw); nil for an interrupted run.
49
+ def finish(interrupted: false)
50
+ return if interrupted
51
+
52
+ total = Delta.capture(::Coverage.peek_result, Set.new, method(:keep?))
53
+ cells = 0
54
+ frames.each do |frame|
55
+ Delta.merge!(total, frame[:delta])
56
+ cells += frame[:lost]
57
+ end
58
+ Raw.build(normalize(total), root: @root, criteria: @criteria, lost: { workers: @lost, cells: })
59
+ ensure
60
+ FileUtils.rm_rf(@frames) if @frames
61
+ end
62
+
63
+ private
64
+
65
+ # Files under the project root, which exist (not eval'd code).
66
+ def keep?(path)
67
+ @kept.fetch(path) { @kept[path] = @roots.any? { path.start_with?(it) } && File.file?(path) }
68
+ end
69
+
70
+ # Every complete frame of every worker. A worker killed while writing
71
+ # leaves a truncated last frame, skipped.
72
+ def frames
73
+ Dir[File.join(@frames, '*.cov')].flat_map do |file|
74
+ data = File.binread(file)
75
+ offset = 0
76
+ frames = []
77
+ while offset + 4 <= data.bytesize
78
+ size = data.unpack1('N', offset:)
79
+ break if offset + 4 + size > data.bytesize
80
+
81
+ frames << Marshal.load(data.byteslice(offset + 4, size)) # written by our own workers
82
+ offset += 4 + size
83
+ end
84
+ frames
85
+ end
86
+ end
87
+
88
+ # Keys by real path relative to the root: a file required through a
89
+ # symlink and through its real path is one file. A file deleted during
90
+ # the run is not reported.
91
+ def normalize(total)
92
+ total.each_with_object({}) do |(path, entry), files|
93
+ relative = File.realpath(path).delete_prefix("#{@root}/")
94
+ next if relative.start_with?('/')
95
+
96
+ files[relative] ? Delta.merge!(files, { relative => entry }) : files[relative] = entry
97
+ rescue SystemCallError
98
+ next
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # A PaddedCell.coverage block, in the test helper:
6
+ #
7
+ # PaddedCell.coverage(:rails) do
8
+ # track "{app,lib}/**/*.rb"
9
+ # skip "app/legacy/"
10
+ # group "Operations", "app/concepts"
11
+ # minimum line: 90, branch: 80
12
+ # minimum_by_file line: 50
13
+ # minimum_by_group "Models", line: 95
14
+ # maximum_drop line: 1
15
+ # formats :markdown, :lcov, :json
16
+ # end
17
+ #
18
+ # Filters and groups match paths (see Filter). What is
19
+ # measured is decided before the suite loads, on the command line:
20
+ # --coverage=lines,branches,methods.
21
+ class Config
22
+ FORMATS = %i[markdown lcov json].freeze
23
+ # Left out unless cleared: bundled gems, hidden files, test files.
24
+ DEFAULT_FILTERS = ['/vendor/bundle/', /\A\..*/, %r{\A(test|features|spec|autotest)/}].freeze
25
+ RAILS_GROUPS = {
26
+ 'Channels' => 'app/channels', 'Controllers' => 'app/controllers', 'Helpers' => 'app/helpers',
27
+ 'Jobs' => %w[app/jobs app/workers], 'Libraries' => 'lib/', 'Mailers' => 'app/mailers',
28
+ 'Models' => 'app/models', 'Views' => 'app/views'
29
+ }.freeze
30
+
31
+ attr_reader :filters, :groups, :tracked, :output_formats, :directory, :token, :interval, :minimums,
32
+ :file_minimums, :group_minimums, :maximum_drops
33
+
34
+ def initialize
35
+ @filters = DEFAULT_FILTERS.map { Filter.build(it) }
36
+ @groups = {}
37
+ @tracked = []
38
+ @minimums = {}
39
+ @file_minimums = {}
40
+ @group_minimums = {}
41
+ @maximum_drops = {}
42
+ @output_formats = %i[markdown lcov json]
43
+ @directory = 'coverage'
44
+ @token = 'nocov'
45
+ @interval = Probe::INTERVAL
46
+ end
47
+
48
+ # :rails skips config/ and db/, groups the
49
+ # app's directories, tracks {app,lib}/**/*.rb.
50
+ def profile(name)
51
+ raise ArgumentError, "unknown coverage profile #{name.inspect} (known: :rails)" unless name.to_s == 'rails'
52
+
53
+ skip(%r{\Aconfig/}, %r{\Adb/})
54
+ RAILS_GROUPS.each { |group_name, filter| group(group_name, filter) }
55
+ track('{app,lib}/**/*.rb')
56
+ end
57
+
58
+ # Files to report even when the suite never loads them, at 0%.
59
+ def track(*globs) = @tracked.concat(globs)
60
+
61
+ # Files to leave out of the report.
62
+ def skip(*filters, &block)
63
+ filters.each { @filters << Filter.build(it) }
64
+ @filters << Filter.build(&block) if block
65
+ end
66
+ alias filter skip
67
+
68
+ # Removes every filter, the defaults included.
69
+ def clear_filters = @filters.clear
70
+
71
+ # A file can be in several groups; files in none are Ungrouped.
72
+ # Defining a group again replaces it.
73
+ def group(name, filter = nil, &) = @groups[name.to_s] = Filter.build(filter, &)
74
+
75
+ # Thresholds compare percents floored to 2 decimals; a whole, passing
76
+ # run that misses one exits 3.
77
+ def minimum(line: nil, branch: nil, method: nil) = @minimums.merge!({ line:, branch:, method: }.compact)
78
+
79
+ # For every file.
80
+ def minimum_by_file(line: nil, branch: nil, method: nil)
81
+ @file_minimums.merge!({ line:, branch:, method: }.compact)
82
+ end
83
+
84
+ def minimum_by_group(name, line: nil, branch: nil, method: nil)
85
+ (@group_minimums[name.to_s] ||= {}).merge!({ line:, branch:, method: }.compact)
86
+ end
87
+
88
+ # How many points coverage may lose since the previous whole run.
89
+ def maximum_drop(line: nil, branch: nil, method: nil)
90
+ @maximum_drops.merge!({ line:, branch:, method: }.compact)
91
+ end
92
+
93
+ def formats(*names)
94
+ unknown = names.map(&:to_sym) - FORMATS
95
+ unless unknown.empty?
96
+ raise ArgumentError, "unknown coverage formats: #{unknown.join(', ')} (known: #{FORMATS.join(', ')})"
97
+ end
98
+
99
+ @output_formats = names.map(&:to_sym)
100
+ end
101
+
102
+ # Where reports go (default: coverage). --coverage-dir wins.
103
+ def dir(path) = @directory = path
104
+
105
+ # `# :nocov:` opens and closes blocks of lines coverage ignores.
106
+ def nocov_token(token) = @token = token
107
+
108
+ # How often, at least, a worker hands over what it measured (seconds,
109
+ # default 10, stretched to keep it under 1% of the worker's time; 0:
110
+ # after every test). A worker that dies loses what it measured since.
111
+ def flush_interval(seconds) = @interval = seconds
112
+
113
+ # What a report needs once files are filtered and grouped, as stored
114
+ # in its Document.
115
+ def settings
116
+ {
117
+ formats: @output_formats.map(&:to_s), nocov_token: @token, minimum: @minimums,
118
+ minimum_by_file: @file_minimums, minimum_by_group: @group_minimums, maximum_drop: @maximum_drops
119
+ }
120
+ end
121
+
122
+ # Criteria come from the command line, before the suite (and this
123
+ # block) loads.
124
+ def enable_coverage(*)
125
+ raise ArgumentError, 'coverage criteria come from --coverage=lines,branches,methods, not the test helper'
126
+ end
127
+ end
128
+ end
129
+ end