padded_cell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/Cargo.lock +518 -0
  4. data/Cargo.toml +41 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +184 -0
  7. data/exe/padded_cell +6 -0
  8. data/ext/padded_cell_native/core/Cargo.toml +20 -0
  9. data/ext/padded_cell_native/core/README.md +50 -0
  10. data/ext/padded_cell_native/core/src/discover/tests.rs +107 -0
  11. data/ext/padded_cell_native/core/src/discover.rs +454 -0
  12. data/ext/padded_cell_native/core/src/hash/tests.rs +13 -0
  13. data/ext/padded_cell_native/core/src/hash.rs +13 -0
  14. data/ext/padded_cell_native/core/src/lib.rs +30 -0
  15. data/ext/padded_cell_native/core/src/plan/tests.rs +61 -0
  16. data/ext/padded_cell_native/core/src/plan.rs +109 -0
  17. data/ext/padded_cell_native/core/src/rng/tests.rs +17 -0
  18. data/ext/padded_cell_native/core/src/rng.rs +28 -0
  19. data/ext/padded_cell_native/extconf.rb +46 -0
  20. data/ext/padded_cell_native/ffi/Cargo.toml +19 -0
  21. data/ext/padded_cell_native/ffi/src/lib.rs +120 -0
  22. data/lib/padded_cell/active_support_hooks.rb +30 -0
  23. data/lib/padded_cell/cell.rb +87 -0
  24. data/lib/padded_cell/cli.rb +97 -0
  25. data/lib/padded_cell/discovery.rb +24 -0
  26. data/lib/padded_cell/hooks.rb +33 -0
  27. data/lib/padded_cell/loader.rb +82 -0
  28. data/lib/padded_cell/native.rb +23 -0
  29. data/lib/padded_cell/pool.rb +202 -0
  30. data/lib/padded_cell/railtie.rb +18 -0
  31. data/lib/padded_cell/reporters.rb +106 -0
  32. data/lib/padded_cell/results.rb +108 -0
  33. data/lib/padded_cell/runner.rb +131 -0
  34. data/lib/padded_cell/scheduler.rb +21 -0
  35. data/lib/padded_cell/selection.rb +78 -0
  36. data/lib/padded_cell/timings.rb +30 -0
  37. data/lib/padded_cell/version.rb +5 -0
  38. data/lib/padded_cell.rb +29 -0
  39. metadata +113 -0
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module PaddedCell
6
+ # Forked workers fed one test at a time over a socket pair. A worker writes
7
+ # back each Minitest::Result as a length-prefixed Marshal frame.
8
+ #
9
+ # Work comes in batches, one per class: a worker takes a whole class, so
10
+ # per-class setup (fixtures) happens once. When no class is left in the
11
+ # queue, an idle worker takes the second half of the largest remainder of
12
+ # another worker's class, so one big class still spreads over all workers.
13
+ #
14
+ # With isolation :test a worker forks a Cell per test; with :worker it runs
15
+ # tests itself, faster but sharing process state. A worker that dies
16
+ # mid-test, or runs past the timeout, gets its test reported as an error and
17
+ # is replaced; its replacement continues the class.
18
+ class Pool
19
+ Slot = Struct.new(:worker, :pid, :socket, :test, :started, :batch)
20
+
21
+ # tests: [[klass, method_name], ...], dispatched by index.
22
+ def initialize(tests, jobs:, isolation:, seed:, timeout: nil)
23
+ @tests = tests
24
+ @jobs = jobs
25
+ @isolation = isolation
26
+ @seed = seed
27
+ @timeout = timeout
28
+ end
29
+
30
+ # Runs batches of test indices in order (consuming them), yielding
31
+ # (index, result) as results arrive.
32
+ def run(batches, &on_result)
33
+ @queue = batches
34
+ @on_result = on_result
35
+ @sockets = []
36
+ Hooks.run_before_fork
37
+ @slots = Array.new([@jobs, batches.sum(&:size)].min) { spawn(Slot.new(it, nil, nil, nil, nil, [])) }
38
+ @slots.each { feed(it) }
39
+ loop do
40
+ busy = @slots.select(&:test)
41
+ break if busy.empty?
42
+
43
+ ready, = IO.select(busy.map(&:socket), nil, nil, wait_time(busy))
44
+ ready ? ready.each { |io| receive(busy.find { it.socket == io }) } : expire(busy)
45
+ end
46
+ ensure
47
+ shutdown(force: !$!.nil?)
48
+ end
49
+
50
+ # Dispatch nothing more; tests already running still report.
51
+ def stop!
52
+ @queue.clear
53
+ @slots.each { it.batch.clear }
54
+ end
55
+
56
+ private
57
+
58
+ def spawn(slot)
59
+ parent, child = UNIXSocket.pair
60
+ $stdout.flush
61
+ $stderr.flush
62
+ slot.pid = Process.fork do
63
+ # A worker holding another worker's socket would keep it from ever
64
+ # seeing EOF: keep only its own end.
65
+ [parent, *@sockets].each(&:close)
66
+ work(slot.worker, child)
67
+ end
68
+ child.close
69
+ @sockets << parent
70
+ slot.socket = parent
71
+ slot
72
+ end
73
+
74
+ # The worker process.
75
+ def work(worker, socket)
76
+ status = 1
77
+ begin
78
+ srand(@seed)
79
+ setup_error = after_fork(worker)
80
+ while (line = socket.gets)
81
+ payload = Results.dump(perform(Integer(line), socket, setup_error))
82
+ socket.write([payload.bytesize].pack('N'), payload)
83
+ end
84
+ Hooks.run_cleanup(worker) unless setup_error
85
+ status = 0
86
+ rescue Exception => e # anything: report it, then exit!
87
+ warn "padded_cell: worker #{worker} failed: #{e.class}: #{e.message}"
88
+ ensure
89
+ exit!(status)
90
+ end
91
+ end
92
+
93
+ # A failing after_fork hook fails every test of the worker, the way Rails
94
+ # parallel testing reports a broken parallelize_setup.
95
+ def after_fork(worker)
96
+ Hooks.run_after_fork(worker)
97
+ nil
98
+ rescue StandardError => e
99
+ e
100
+ end
101
+
102
+ def perform(index, socket, setup_error)
103
+ klass, name = @tests.fetch(index)
104
+ if setup_error
105
+ Results.crashed(klass, name, "after_fork hook failed: #{setup_error.class}: #{setup_error.message}")
106
+ elsif @isolation == :test
107
+ Cell.run(klass, name, seed: @seed, timeout: @timeout, close: [socket])
108
+ else
109
+ Cell.execute(klass, name)
110
+ end
111
+ end
112
+
113
+ def feed(slot)
114
+ slot.batch = @queue.shift || steal if slot.batch.empty?
115
+ slot.test = slot.batch.shift
116
+ return unless slot.test
117
+
118
+ slot.started = Minitest.clock_time
119
+ begin
120
+ slot.socket.puts(slot.test)
121
+ rescue Errno::EPIPE, Errno::ECONNRESET
122
+ # The worker died between tests: its replacement gets this one.
123
+ reap(slot)
124
+ spawn(slot).socket.puts(slot.test)
125
+ end
126
+ end
127
+
128
+ # The second half of the largest remainder of another class.
129
+ def steal
130
+ victim = @slots.max_by { it.batch.size }
131
+ victim.batch.pop(victim.batch.size - (victim.batch.size / 2))
132
+ end
133
+
134
+ def receive(slot)
135
+ header = slot.socket.read(4)
136
+ return died(slot) unless header&.bytesize == 4
137
+
138
+ size = header.unpack1('N')
139
+ payload = slot.socket.read(size)
140
+ return died(slot) unless payload&.bytesize == size
141
+
142
+ report(slot, Results.load(payload, *@tests.fetch(slot.test)))
143
+ feed(slot)
144
+ end
145
+
146
+ def report(slot, result)
147
+ index = slot.test
148
+ slot.test = nil
149
+ @on_result.call(index, result)
150
+ end
151
+
152
+ def died(slot)
153
+ status = reap(slot)
154
+ replace(slot, Results.describe(status, 'worker'), Minitest.clock_time - slot.started)
155
+ end
156
+
157
+ # Cells time out inside their worker; with :worker isolation the runner
158
+ # kills the worker itself.
159
+ def wait_time(busy)
160
+ return unless @timeout && @isolation == :worker
161
+
162
+ deadline = busy.map { it.started + @timeout }.min
163
+ [deadline - Minitest.clock_time, 0].max
164
+ end
165
+
166
+ def expire(busy)
167
+ busy.each do |slot|
168
+ next if Minitest.clock_time - slot.started < @timeout
169
+
170
+ Process.kill(:KILL, slot.pid)
171
+ reap(slot)
172
+ replace(slot, "timed out after #{@timeout}s", @timeout)
173
+ end
174
+ end
175
+
176
+ # Reports the running test of a dead worker and starts a new one.
177
+ def replace(slot, message, time)
178
+ klass, name = @tests.fetch(slot.test)
179
+ report(slot, Results.crashed(klass, name, message, time:))
180
+ return if @queue.empty? && @slots.all? { it.batch.empty? }
181
+
182
+ spawn(slot)
183
+ feed(slot)
184
+ end
185
+
186
+ def reap(slot)
187
+ @sockets.delete(slot.socket)
188
+ slot.socket.close
189
+ _, status = Process.wait2(slot.pid)
190
+ slot.pid = nil
191
+ status
192
+ end
193
+
194
+ # Closing a socket tells its worker to finish. On an error in the runner
195
+ # (Interrupt included) workers are killed instead of awaited.
196
+ def shutdown(force:)
197
+ live = Array(@slots).select(&:pid)
198
+ live.each { Process.kill(:KILL, it.pid) } if force
199
+ live.each { reap(it) }
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ # `bin/rails padded_cell` / `rake padded_cell`, with arguments in ARGS:
5
+ #
6
+ # bin/rails padded_cell ARGS="-j 8 test/models"
7
+ class Railtie < Rails::Railtie
8
+ rake_tasks do
9
+ desc 'Run the test suite with padded_cell (arguments in ARGS)'
10
+ task :padded_cell do
11
+ require 'padded_cell/cli'
12
+ require 'shellwords'
13
+ status = PaddedCell::CLI.start(Shellwords.split(ENV.fetch('ARGS', '')))
14
+ exit status unless status.zero?
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module PaddedCell
7
+ # File reporters for CI, next to Minitest's own console output.
8
+ module Reporters
9
+ # Collects results, writes them all at the end.
10
+ class FileReporter < Minitest::AbstractReporter
11
+ def initialize(path)
12
+ super()
13
+ @path = path
14
+ @results = []
15
+ end
16
+
17
+ def record(result) = @results << result
18
+
19
+ def report
20
+ FileUtils.mkdir_p(File.dirname(@path))
21
+ File.write(@path, render(@results))
22
+ end
23
+
24
+ private
25
+
26
+ def status(result)
27
+ return :skipped if result.skipped?
28
+ return :error if result.error?
29
+
30
+ result.passed? ? :passed : :failed
31
+ end
32
+
33
+ def file_and_line(result)
34
+ file, line = result.source_location
35
+ [relative(file.to_s), line]
36
+ end
37
+
38
+ def relative(file) = file.delete_prefix("#{Dir.pwd}/")
39
+ end
40
+
41
+ # JUnit XML, as read by GitHub, GitLab, Jenkins and friends.
42
+ class JUnit < FileReporter
43
+ ESCAPES = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&apos;' }.freeze
44
+ # XML 1.0 has no place for most control characters.
45
+ INVALID = /[^\t\n\r -\u{D7FF}\u{E000}-\u{FFFD}\u{10000}-\u{10FFFF}]/
46
+
47
+ private
48
+
49
+ def render(results)
50
+ suites = results.group_by(&:klass).map { |klass, cases| suite(klass, cases) }
51
+ %(<?xml version="1.0" encoding="UTF-8"?>\n<testsuites name="padded_cell" #{counts(results)}>\n) +
52
+ suites.join + "</testsuites>\n"
53
+ end
54
+
55
+ def suite(klass, results)
56
+ %( <testsuite name="#{escape(klass)}" #{counts(results)}>\n) +
57
+ results.map { testcase(it) }.join + " </testsuite>\n"
58
+ end
59
+
60
+ def counts(results)
61
+ statuses = results.map { status(it) }
62
+ %(tests="#{results.size}" failures="#{statuses.count(:failed)}" errors="#{statuses.count(:error)}" ) +
63
+ %(skipped="#{statuses.count(:skipped)}" time="#{format('%.6f', results.sum(&:time))}")
64
+ end
65
+
66
+ def testcase(result)
67
+ file, line = file_and_line(result)
68
+ attributes = %(classname="#{escape(result.klass)}" name="#{escape(result.name)}" file="#{escape(file)}" ) +
69
+ %(line="#{line}" time="#{format('%.6f', result.time)}" assertions="#{result.assertions}")
70
+ body = outcome(result)
71
+ body.empty? ? %( <testcase #{attributes}/>\n) : %( <testcase #{attributes}>\n#{body} </testcase>\n)
72
+ end
73
+
74
+ def outcome(result)
75
+ failure = result.failure
76
+ case status(result)
77
+ when :skipped then %( <skipped message="#{escape(failure.message)}"/>\n)
78
+ when :error, :failed
79
+ tag = status(result) == :error ? 'error' : 'failure'
80
+ type = failure.is_a?(Minitest::UnexpectedError) ? failure.error.class : failure.class
81
+ %( <#{tag} type="#{escape(type.to_s)}" message="#{escape(failure.message.lines.first.to_s.strip)}">) +
82
+ %(#{escape(failure.message)}</#{tag}>\n)
83
+ else ''
84
+ end
85
+ end
86
+
87
+ def escape(text) = text.to_s.scrub.gsub(INVALID, '').gsub(/[&<>"']/, ESCAPES)
88
+ end
89
+
90
+ # One JSON object per test, one per line.
91
+ class JSONLines < FileReporter
92
+ private
93
+
94
+ def render(results) = results.map { "#{JSON.generate(entry(it))}\n" }.join
95
+
96
+ def entry(result)
97
+ file, line = file_and_line(result)
98
+ {
99
+ id: "#{result.klass}##{result.name}", class: result.klass, name: result.name, file:, line:,
100
+ status: status(result), time: result.time, assertions: result.assertions,
101
+ failures: result.failures.map { { type: it.result_label, message: it.message.scrub } }
102
+ }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ # Raised (in a result, never for real) for a test that did not report:
5
+ # its process crashed, exited or timed out.
6
+ class CellCrashed < StandardError; end
7
+
8
+ # Minitest::Result helpers.
9
+ module Results
10
+ module_function
11
+
12
+ # An error result for a test that never reported.
13
+ def crashed(klass, name, message, time: 0.0)
14
+ result = Minitest::Result.new(name)
15
+ result.klass = klass.name
16
+ result.time = time
17
+ result.source_location = source_location(klass, name)
18
+ error = CellCrashed.new(message)
19
+ error.set_backtrace(["#{result.source_location.join(':')}:in '#{klass}##{name}'"])
20
+ result.failures = [Minitest::UnexpectedError.new(error)]
21
+ result
22
+ end
23
+
24
+ # Why a process ended without reporting.
25
+ def describe(status, process)
26
+ return "#{process} killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
27
+
28
+ "#{process} exited with status #{status.exitstatus} before reporting"
29
+ end
30
+
31
+ MISSING_CONSTANT = %r{\Aundefined class/module (\S+)\z}
32
+ # Nesting deeper than this is not a result anyone wrote.
33
+ PLACEHOLDER_LIMIT = 32
34
+
35
+ # A result from a worker or cell, or an error result for klass#name when
36
+ # it cannot be loaded. Never raises: one bad result must not end the run.
37
+ #
38
+ # The sender may have loaded classes after it was forked (an error class
39
+ # Rails autoloads during a test, a class a test defines): placeholders
40
+ # named like them stand in during the load, then are removed again, so
41
+ # the failure keeps its class name, message and backtrace.
42
+ def load(payload, klass, name)
43
+ placeholders = []
44
+ begin
45
+ Marshal.load(payload) # written by our own worker or cell
46
+ rescue ArgumentError => e
47
+ missing = MISSING_CONSTANT.match(e.message)&.[](1)
48
+ raise unless missing && placeholders.size < PLACEHOLDER_LIMIT && placeholder(missing, placeholders)
49
+
50
+ retry
51
+ end
52
+ rescue StandardError => e
53
+ crashed(klass, name, "its result could not be loaded: #{e.class}: #{e.message}")
54
+ ensure
55
+ placeholders.reverse_each { |owner, constant| owner.send(:remove_const, constant) }
56
+ end
57
+
58
+ # Defines the first missing constant of path (a module on the way, an
59
+ # error class at the end). False when path cannot be made to exist.
60
+ def placeholder(path, placeholders)
61
+ owner = Object
62
+ *namespaces, last = path.split('::')
63
+ namespaces.each do |segment|
64
+ unless owner.const_defined?(segment, false)
65
+ owner.const_set(segment, Module.new)
66
+ return placeholders << [owner, segment]
67
+ end
68
+ owner = owner.const_get(segment, false)
69
+ return false unless owner.is_a?(Module)
70
+ end
71
+ return false if owner.const_defined?(last, false)
72
+
73
+ owner.const_set(last, Class.new(StandardError))
74
+ placeholders << [owner, last]
75
+ end
76
+
77
+ # Marshal.dump, after replacing failures that cannot cross a pipe (an
78
+ # exception holding a Proc, an IO...) with plain copies.
79
+ def dump(result)
80
+ Marshal.dump(result)
81
+ rescue TypeError, ArgumentError
82
+ result.failures = result.failures.map { portable(it) }
83
+ result.metadata = {} if result.metadata?
84
+ Marshal.dump(result)
85
+ end
86
+
87
+ def portable(failure)
88
+ Marshal.dump(failure)
89
+ failure
90
+ rescue TypeError, ArgumentError
91
+ error = failure.is_a?(Minitest::UnexpectedError) ? failure.error : failure
92
+ message = "#{error.class}: #{error.message}"
93
+ copy = case failure
94
+ when Minitest::UnexpectedError then Minitest::UnexpectedError.new(RuntimeError.new(message))
95
+ when Minitest::Skip then Minitest::Skip.new(error.message)
96
+ else Minitest::Assertion.new(message)
97
+ end
98
+ (copy.is_a?(Minitest::UnexpectedError) ? copy.error : copy).set_backtrace(error.backtrace || [])
99
+ copy
100
+ end
101
+
102
+ def source_location(klass, name)
103
+ klass.instance_method(name).source_location || ['unknown', -1]
104
+ rescue NameError
105
+ ['unknown', -1]
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+ require 'minitest'
5
+ require_relative '../padded_cell'
6
+ require_relative 'results'
7
+ require_relative 'cell'
8
+ require_relative 'pool'
9
+ require_relative 'loader'
10
+ require_relative 'timings'
11
+ require_relative 'reporters'
12
+
13
+ module PaddedCell
14
+ # Running tests needs fork: Windows (and the JVM Rubies) are not supported.
15
+ class Unsupported < StandardError; end
16
+
17
+ # Runs a Minitest suite: select, discover, load once, order, fork, report.
18
+ class Runner
19
+ DEFAULTS = {
20
+ paths: [], jobs: Etc.nprocessors, seed: nil, include: nil, exclude: nil, shard: [0, 1],
21
+ isolation: nil, timeout: nil, fail_fast: false, verbose: false, show_skips: false,
22
+ list: false, junit: nil, json: nil, timings: 'tmp/padded_cell/timings.tsv', io: $stdout
23
+ }.freeze
24
+
25
+ attr_reader :options
26
+
27
+ def initialize(**options)
28
+ @options = DEFAULTS.merge(options)
29
+ @options[:seed] ||= (ENV['SEED'] || Random.new_seed).to_i % 0xFFFF
30
+ end
31
+
32
+ # Exit status: 0 when everything passed, 1 otherwise, 130 on Ctrl-C.
33
+ def run
34
+ selection = Selection.parse(options[:paths])
35
+ refs = Discovery.call(selection.files)
36
+ return list(selection, refs) if options[:list]
37
+ raise Unsupported, 'padded_cell needs fork, which this Ruby does not have' unless Process.respond_to?(:fork)
38
+
39
+ Loader.call(selection.files, seed: options[:seed])
40
+ if defined?(ActiveSupport::TestCase)
41
+ require_relative 'active_support_hooks'
42
+ ActiveSupportHooks.install
43
+ end
44
+ options[:isolation] ||= default_isolation
45
+ filter = Filter.new(selection, refs, include: options[:include], exclude: options[:exclude])
46
+ execute(Loader.tests.select { filter.call(*it) })
47
+ end
48
+
49
+ private
50
+
51
+ # A fork per test discards Active Record's connection pool, and with it
52
+ # the fixtures loaded through it: every test would reload them. With
53
+ # fixtures around, tests share their worker instead.
54
+ def default_isolation = defined?(ActiveRecord::FixtureSet) ? :worker : :test
55
+
56
+ # Tests as they would run (static discovery, no loading), in file order.
57
+ def list(selection, refs)
58
+ refs = refs.select { selection.covers?(it.file, it.lines) }
59
+ kept = Scheduler.batches(refs.map(&:class_name), seed: options[:seed], shard: options[:shard]).flatten.sort
60
+ kept.each do |index|
61
+ ref = refs[index]
62
+ options[:io].puts "#{ref.file.delete_prefix("#{Dir.pwd}/")}:#{ref.start_line} #{ref.id}"
63
+ end
64
+ 0
65
+ end
66
+
67
+ def execute(tests)
68
+ ids = tests.map { |klass, name| "#{klass}##{name}" }
69
+ timings = Timings.load(options[:timings])
70
+ batches = Scheduler.batches(tests.map { |klass, _| klass.to_s }, durations: ids.map { timings[it] },
71
+ seed: options[:seed], shard: options[:shard])
72
+ reporter = build_reporter
73
+ reporter.start
74
+ interrupted = dispatch(tests, batches, reporter) { |index, result| timings[ids[index]] = result.time }
75
+ reporter.report
76
+ Timings.save(options[:timings], timings)
77
+ after_run
78
+ finish(reporter, interrupted, batches.empty?)
79
+ end
80
+
81
+ # Yields each result after reporting it. True when interrupted.
82
+ def dispatch(tests, batches, reporter)
83
+ pool = nil
84
+ on_result = lambda do |index, result|
85
+ reporter.prerecord(*tests[index])
86
+ reporter.record(result)
87
+ yield index, result
88
+ pool&.stop! if options[:fail_fast] && !result.passed? && !result.skipped?
89
+ end
90
+ pool = Pool.new(tests, jobs: options[:jobs], isolation: options[:isolation], seed: options[:seed],
91
+ timeout: options[:timeout])
92
+ pool.run(batches, &on_result)
93
+ false
94
+ rescue Interrupt
95
+ warn 'Interrupted. Exiting...'
96
+ true
97
+ end
98
+
99
+ def build_reporter
100
+ reporter = Minitest::CompositeReporter.new
101
+ minitest_options = {
102
+ io: options[:io], seed: options[:seed], args: "--seed #{options[:seed]} --isolation #{options[:isolation]}",
103
+ verbose: options[:verbose], show_skips: options[:show_skips]
104
+ }
105
+ reporter << Minitest::SummaryReporter.new(options[:io], minitest_options)
106
+ reporter << Minitest::ProgressReporter.new(options[:io], minitest_options)
107
+ # Plugins (minitest-reporters, Rails...) may add or swap reporters.
108
+ Minitest.reporter = reporter
109
+ Minitest.init_plugins(minitest_options)
110
+ Minitest.reporter = nil
111
+ reporter << Reporters::JUnit.new(options[:junit]) if options[:junit]
112
+ reporter << Reporters::JSONLines.new(options[:json]) if options[:json]
113
+ reporter
114
+ end
115
+
116
+ # Minitest.after_run blocks, which autorun would have called.
117
+ def after_run
118
+ Minitest.class_variable_get(:@@after_run).reverse_each(&:call)
119
+ end
120
+
121
+ def finish(reporter, interrupted, nothing_ran)
122
+ return 130 if interrupted
123
+ if nothing_ran && options[:include]
124
+ warn "Nothing ran for filter: #{options[:include]}"
125
+ return 1
126
+ end
127
+
128
+ reporter.passed? ? 0 : 1
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ # Deterministic run order: tests grouped by class, shards of whole classes,
5
+ # seeded shuffle, slowest classes (and tests) first.
6
+ module Scheduler
7
+ UNKNOWN = -1.0
8
+
9
+ module_function
10
+
11
+ # Batches of test indices, one per class, in run order, for one shard.
12
+ #
13
+ # classes: class name of each test
14
+ # durations: seconds per test (nil when unknown)
15
+ # shard: [index, count], 0-based
16
+ def batches(classes, durations: Array.new(classes.size), seed: 0, shard: [0, 1])
17
+ index, count = shard
18
+ PaddedCellNative.plan(classes, durations.map { it || UNKNOWN }, seed, index, count)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ # What to run, from command line arguments, the way `rails test` reads them:
5
+ #
6
+ # (none) test/**/*_test.rb, minus test/{system,dummy,fixtures}
7
+ # test/models every *_test.rb below it
8
+ # test/models/a_test.rb that file
9
+ # a_test.rb:12 the test whose definition spans line 12
10
+ # a_test.rb:12-20:31 tests overlapping 12..20, or spanning 31
11
+ # 'test/**/*_x_test.rb' a glob
12
+ class Selection
13
+ DEFAULT_GLOB = 'test/**/*_test.rb'
14
+ DEFAULT_EXCLUDE = 'test/{system,dummy,fixtures}/**/*_test.rb'
15
+ LINES = /(?::\d+(?:-\d+)?)+\z/
16
+
17
+ # Absolute paths, sorted.
18
+ attr_reader :files
19
+
20
+ def self.parse(args, root: Dir.pwd) = new(args, root)
21
+
22
+ def initialize(args, root)
23
+ @root = root
24
+ @lines = Hash.new { |hash, file| hash[file] = [] }
25
+ @files = (args.empty? ? defaults : args.flat_map { expand(it) }).uniq.sort
26
+ end
27
+
28
+ # Line ranges asked for each file; empty when the whole file runs.
29
+ def lines_for(file) = @lines.fetch(file, [])
30
+
31
+ def line_filtered? = @lines.any?
32
+
33
+ # Whether a test defined in file over lines is selected by line.
34
+ def covers?(file, lines)
35
+ ranges = lines_for(file)
36
+ ranges.empty? || ranges.any? { it.begin <= lines.end && lines.begin <= it.end }
37
+ end
38
+
39
+ private
40
+
41
+ def defaults
42
+ Dir.glob(DEFAULT_GLOB, base: @root).map { absolute(it) } -
43
+ Dir.glob(DEFAULT_EXCLUDE, base: @root).map { absolute(it) }
44
+ end
45
+
46
+ def expand(arg)
47
+ if (match = LINES.match(arg))
48
+ file = existing_file(match.pre_match, arg)
49
+ @lines[file].concat(ranges(match[0]))
50
+ [file]
51
+ elsif glob?(arg)
52
+ Dir.glob(arg, base: @root).map { absolute(it) }
53
+ elsif File.directory?(absolute(arg))
54
+ Dir.glob('**/*_test.rb', base: absolute(arg)).map { File.join(absolute(arg), it) }
55
+ else
56
+ [existing_file(arg, arg)]
57
+ end
58
+ end
59
+
60
+ def ranges(spec)
61
+ spec.delete_prefix(':').split(':').map do |part|
62
+ first, last = part.split('-').map { Integer(it, 10) }
63
+ first..(last || first)
64
+ end
65
+ end
66
+
67
+ def existing_file(path, arg)
68
+ file = absolute(path)
69
+ raise ArgumentError, "no such test file: #{arg}" unless File.file?(file)
70
+
71
+ file
72
+ end
73
+
74
+ def glob?(arg) = arg.match?(/[*?\[{]/)
75
+
76
+ def absolute(path) = File.expand_path(path, @root)
77
+ end
78
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module PaddedCell
6
+ # Seconds per test id from earlier runs, kept as "id<TAB>seconds" lines.
7
+ # The scheduler starts the slowest tests first.
8
+ module Timings
9
+ module_function
10
+
11
+ def load(path)
12
+ return {} unless path && File.file?(path)
13
+
14
+ File.foreach(path, chomp: true).to_h do |line|
15
+ id, seconds = line.split("\t", 2)
16
+ [id, Float(seconds)]
17
+ end
18
+ end
19
+
20
+ # Replaces the file atomically.
21
+ def save(path, timings)
22
+ return unless path
23
+
24
+ FileUtils.mkdir_p(File.dirname(path))
25
+ temp = "#{path}.#{Process.pid}"
26
+ File.write(temp, timings.sort.map { |id, seconds| "#{id}\t#{seconds}\n" }.join)
27
+ File.rename(temp, path)
28
+ end
29
+ end
30
+ end