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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/Cargo.lock +518 -0
- data/Cargo.toml +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/exe/padded_cell +6 -0
- data/ext/padded_cell_native/core/Cargo.toml +20 -0
- data/ext/padded_cell_native/core/README.md +50 -0
- data/ext/padded_cell_native/core/src/discover/tests.rs +107 -0
- data/ext/padded_cell_native/core/src/discover.rs +454 -0
- data/ext/padded_cell_native/core/src/hash/tests.rs +13 -0
- data/ext/padded_cell_native/core/src/hash.rs +13 -0
- data/ext/padded_cell_native/core/src/lib.rs +30 -0
- data/ext/padded_cell_native/core/src/plan/tests.rs +61 -0
- data/ext/padded_cell_native/core/src/plan.rs +109 -0
- data/ext/padded_cell_native/core/src/rng/tests.rs +17 -0
- data/ext/padded_cell_native/core/src/rng.rs +28 -0
- data/ext/padded_cell_native/extconf.rb +46 -0
- data/ext/padded_cell_native/ffi/Cargo.toml +19 -0
- data/ext/padded_cell_native/ffi/src/lib.rs +120 -0
- data/lib/padded_cell/active_support_hooks.rb +30 -0
- data/lib/padded_cell/cell.rb +87 -0
- data/lib/padded_cell/cli.rb +97 -0
- data/lib/padded_cell/discovery.rb +24 -0
- data/lib/padded_cell/hooks.rb +33 -0
- data/lib/padded_cell/loader.rb +82 -0
- data/lib/padded_cell/native.rb +23 -0
- data/lib/padded_cell/pool.rb +202 -0
- data/lib/padded_cell/railtie.rb +18 -0
- data/lib/padded_cell/reporters.rb +106 -0
- data/lib/padded_cell/results.rb +108 -0
- data/lib/padded_cell/runner.rb +131 -0
- data/lib/padded_cell/scheduler.rb +21 -0
- data/lib/padded_cell/selection.rb +78 -0
- data/lib/padded_cell/timings.rb +30 -0
- data/lib/padded_cell/version.rb +5 -0
- data/lib/padded_cell.rb +29 -0
- metadata +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 = { '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''' }.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
|