minitest-distributed 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ruby.yml +48 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +63 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +115 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/rake +29 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/bin/srb +29 -0
- data/lib/minitest/distributed.rb +36 -0
- data/lib/minitest/distributed/configuration.rb +53 -0
- data/lib/minitest/distributed/coordinators/coordinator_interface.rb +29 -0
- data/lib/minitest/distributed/coordinators/memory_coordinator.rb +67 -0
- data/lib/minitest/distributed/coordinators/redis_coordinator.rb +387 -0
- data/lib/minitest/distributed/enqueued_runnable.rb +88 -0
- data/lib/minitest/distributed/filters/exclude_filter.rb +35 -0
- data/lib/minitest/distributed/filters/filter_interface.rb +25 -0
- data/lib/minitest/distributed/filters/include_filter.rb +35 -0
- data/lib/minitest/distributed/reporters/distributed_progress_reporter.rb +76 -0
- data/lib/minitest/distributed/reporters/distributed_summary_reporter.rb +48 -0
- data/lib/minitest/distributed/reporters/redis_coordinator_warnings_reporter.rb +61 -0
- data/lib/minitest/distributed/result_aggregate.rb +67 -0
- data/lib/minitest/distributed/result_type.rb +28 -0
- data/lib/minitest/distributed/test_runner.rb +37 -0
- data/lib/minitest/distributed/test_selector.rb +54 -0
- data/lib/minitest/distributed/version.rb +8 -0
- data/lib/minitest/distributed_plugin.rb +51 -0
- data/minitest-distributed.gemspec +50 -0
- data/sorbet/config +2 -0
- data/sorbet/rbi/minitest.rbi +238 -0
- data/sorbet/rbi/rbconfig.rbi +6 -0
- data/sorbet/rbi/redis.rbi +70 -0
- data/sorbet/rbi/winsize.rbi +7 -0
- metadata +142 -0
@@ -0,0 +1,88 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
class EnqueuedRunnable < T::Struct
|
7
|
+
class << self
|
8
|
+
extend T::Sig
|
9
|
+
|
10
|
+
sig { params(identifier: String).returns(T.attached_class) }
|
11
|
+
def from_identifier(identifier)
|
12
|
+
class_name, method_name = identifier.split('#', 2)
|
13
|
+
new(
|
14
|
+
class_name: T.must(class_name),
|
15
|
+
method_name: T.must(method_name),
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
sig { params(runnable: Minitest::Runnable).returns(T.attached_class) }
|
20
|
+
def from_runnable(runnable)
|
21
|
+
new(
|
22
|
+
class_name: T.must(runnable.class.name),
|
23
|
+
method_name: runnable.name,
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
sig { params(result: Minitest::Result).returns(T.attached_class) }
|
28
|
+
def from_result(result)
|
29
|
+
new(class_name: result.class_name, method_name: result.name)
|
30
|
+
end
|
31
|
+
|
32
|
+
sig { params(claims: T::Array[[String, T::Hash[String, String]]]).returns(T::Array[T.attached_class]) }
|
33
|
+
def from_redis_stream_claim(claims)
|
34
|
+
claims.map do |id, runnable_method_info|
|
35
|
+
new(
|
36
|
+
class_name: runnable_method_info.fetch('class_name'),
|
37
|
+
method_name: runnable_method_info.fetch('method_name'),
|
38
|
+
execution_id: id,
|
39
|
+
)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
sig { params(name: String).returns(T.class_of(Minitest::Runnable)) }
|
44
|
+
def find_runnable_class(name)
|
45
|
+
name.split('::')
|
46
|
+
.reduce(Object) { |ns, const| ns.const_get(const) } # rubocop:disable Sorbet/ConstantsFromStrings
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
extend T::Sig
|
51
|
+
|
52
|
+
const :class_name, String
|
53
|
+
const :method_name, String
|
54
|
+
const :execution_id, T.nilable(String), dont_store: true
|
55
|
+
|
56
|
+
# By setting canned failure, we will not actually run the runnable,
|
57
|
+
# but immediately return a result with the canned assertion.x
|
58
|
+
prop :canned_failure, T.nilable(Minitest::Assertion), dont_store: true
|
59
|
+
|
60
|
+
sig { returns(String) }
|
61
|
+
def identifier
|
62
|
+
"#{class_name}##{method_name}"
|
63
|
+
end
|
64
|
+
|
65
|
+
sig { returns(T.class_of(Minitest::Runnable)) }
|
66
|
+
def runnable_class
|
67
|
+
self.class.find_runnable_class(class_name)
|
68
|
+
end
|
69
|
+
|
70
|
+
sig { returns(Minitest::Runnable) }
|
71
|
+
def runnable
|
72
|
+
runnable_class.new(method_name)
|
73
|
+
end
|
74
|
+
|
75
|
+
sig { returns(Minitest::Result) }
|
76
|
+
def run
|
77
|
+
if canned_failure
|
78
|
+
canned_runnable = runnable
|
79
|
+
canned_runnable.time = 0.0
|
80
|
+
canned_runnable.failures << canned_failure
|
81
|
+
Minitest::Result.from(canned_runnable)
|
82
|
+
else
|
83
|
+
Minitest.run_one_method(runnable_class, method_name)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
module Filters
|
7
|
+
class ExcludeFilter
|
8
|
+
extend T::Sig
|
9
|
+
include FilterInterface
|
10
|
+
|
11
|
+
sig { returns(T.any(String, Regexp)) }
|
12
|
+
attr_reader :filter
|
13
|
+
|
14
|
+
sig { params(filter: T.any(String, Regexp)).void }
|
15
|
+
def initialize(filter)
|
16
|
+
@filter = filter
|
17
|
+
if filter.is_a?(String) && (match_info = filter.match(%r%/(.*)/%))
|
18
|
+
@filter = Regexp.new(T.must(match_info[1]))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
sig { override.params(enqueued_runnable: EnqueuedRunnable).returns(T::Array[EnqueuedRunnable]) }
|
23
|
+
def call(enqueued_runnable)
|
24
|
+
# rubocop:disable Style/CaseEquality
|
25
|
+
if filter === enqueued_runnable.method_name || filter === enqueued_runnable.identifier
|
26
|
+
[]
|
27
|
+
else
|
28
|
+
[enqueued_runnable]
|
29
|
+
end
|
30
|
+
# rubocop:enable Style/CaseEquality
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
module Filters
|
7
|
+
# A filter proc is a callable object that changes the list of runnables that will
|
8
|
+
# be executed during the test run. For every runnable, it should return an
|
9
|
+
# array of runnables.
|
10
|
+
#
|
11
|
+
# - If it returns an empty array, the runnable will not be run.
|
12
|
+
# - If it returns a single elemnt array with the passed ion runnable to make no changes.
|
13
|
+
# - It can return an array of enumerables to expand the number of runnables in this test run,
|
14
|
+
# We use this for grinding tests, for instance.
|
15
|
+
module FilterInterface
|
16
|
+
extend T::Sig
|
17
|
+
extend T::Helpers
|
18
|
+
interface!
|
19
|
+
|
20
|
+
sig { abstract.params(runnable_method: EnqueuedRunnable).returns(T::Array[EnqueuedRunnable]) }
|
21
|
+
def call(runnable_method); end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
module Filters
|
7
|
+
class IncludeFilter
|
8
|
+
extend T::Sig
|
9
|
+
include FilterInterface
|
10
|
+
|
11
|
+
sig { returns(T.any(String, Regexp)) }
|
12
|
+
attr_reader :filter
|
13
|
+
|
14
|
+
sig { params(filter: T.any(String, Regexp)).void }
|
15
|
+
def initialize(filter)
|
16
|
+
@filter = filter
|
17
|
+
if filter.is_a?(String) && (match_info = filter.match(%r%/(.*)/%))
|
18
|
+
@filter = Regexp.new(T.must(match_info[1]))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
sig { override.params(enqueued_runnable: EnqueuedRunnable).returns(T::Array[EnqueuedRunnable]) }
|
23
|
+
def call(enqueued_runnable)
|
24
|
+
# rubocop:disable Style/CaseEquality
|
25
|
+
if filter === enqueued_runnable.method_name || filter === enqueued_runnable.identifier
|
26
|
+
[enqueued_runnable]
|
27
|
+
else
|
28
|
+
[]
|
29
|
+
end
|
30
|
+
# rubocop:enable Style/CaseEquality
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'io/console'
|
5
|
+
|
6
|
+
module Minitest
|
7
|
+
module Distributed
|
8
|
+
module Reporters
|
9
|
+
class DistributedPogressReporter < Minitest::Reporter
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { returns(Coordinators::CoordinatorInterface) }
|
13
|
+
attr_reader :coordinator
|
14
|
+
|
15
|
+
sig { params(io: IO, options: T::Hash[Symbol, T.untyped]).void }
|
16
|
+
def initialize(io, options)
|
17
|
+
super
|
18
|
+
if io.tty?
|
19
|
+
io.sync = true
|
20
|
+
end
|
21
|
+
@coordinator = T.let(options[:distributed].coordinator, Coordinators::CoordinatorInterface)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Note: due to batching and parallel tests, we have no guarantee that `prerecord`
|
25
|
+
# and `record` will be called in succession for the same test without calls to
|
26
|
+
# either method being interjected for other tests.
|
27
|
+
#
|
28
|
+
# As a result we have no idea what will be on the last line of the console.
|
29
|
+
# We always clear the full line before printing output.
|
30
|
+
|
31
|
+
sig { override.params(klass: T.class_of(Runnable), name: String).void }
|
32
|
+
def prerecord(klass, name)
|
33
|
+
if io.tty?
|
34
|
+
line_width = clear_current_line
|
35
|
+
io.print("[#{results.acks}/#{results.size}] #{klass}##{name}".slice(0...line_width))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
sig { override.params(result: Minitest::Result).void }
|
40
|
+
def record(result)
|
41
|
+
clear_current_line if io.tty?
|
42
|
+
|
43
|
+
case (result_type = ResultType.of(result))
|
44
|
+
when ResultType::Passed
|
45
|
+
# TODO: warn for tests that are slower than the test timeout.
|
46
|
+
when ResultType::Skipped
|
47
|
+
io.puts("#{result}\n") if options[:verbose]
|
48
|
+
when ResultType::Error, ResultType::Failed
|
49
|
+
io.puts("#{result}\n")
|
50
|
+
else
|
51
|
+
T.absurd(result_type)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
sig { override.void }
|
56
|
+
def report
|
57
|
+
clear_current_line if io.tty?
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
sig { returns(Integer) }
|
63
|
+
def clear_current_line
|
64
|
+
_height, width = IO.console.winsize
|
65
|
+
io.print("\r" + (' ' * width) + "\r")
|
66
|
+
width
|
67
|
+
end
|
68
|
+
|
69
|
+
sig { returns(ResultAggregate) }
|
70
|
+
def results
|
71
|
+
coordinator.combined_results
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
module Reporters
|
7
|
+
class DistributedSummaryReporter < Minitest::Reporter
|
8
|
+
extend T::Sig
|
9
|
+
|
10
|
+
sig { returns(Coordinators::CoordinatorInterface) }
|
11
|
+
attr_reader :coordinator
|
12
|
+
|
13
|
+
sig { params(io: IO, options: T::Hash[Symbol, T.untyped]).void }
|
14
|
+
def initialize(io, options)
|
15
|
+
super
|
16
|
+
io.sync = true
|
17
|
+
@coordinator = T.let(options[:distributed].coordinator, Coordinators::CoordinatorInterface)
|
18
|
+
@start_time = T.let(0.0, Float)
|
19
|
+
end
|
20
|
+
|
21
|
+
sig { override.void }
|
22
|
+
def start
|
23
|
+
@start_time = Minitest.clock_time
|
24
|
+
io.puts("Run options: #{options[:args]}\n\n")
|
25
|
+
end
|
26
|
+
|
27
|
+
sig { override.void }
|
28
|
+
def report
|
29
|
+
duration = format("(in %0.3fs)", Minitest.clock_time - @start_time)
|
30
|
+
|
31
|
+
local_results = coordinator.local_results
|
32
|
+
combined_results = coordinator.combined_results
|
33
|
+
if combined_results == local_results
|
34
|
+
io.puts("Results: #{combined_results} #{duration}")
|
35
|
+
else
|
36
|
+
io.puts("This worker: #{local_results} #{duration}")
|
37
|
+
io.puts("Combined results: #{combined_results}")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
sig { override.returns(T::Boolean) }
|
42
|
+
def passed?
|
43
|
+
coordinator.combined_results.passed?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
module Reporters
|
7
|
+
class RedisCoordinatorWarningsReporter < Minitest::Reporter
|
8
|
+
extend T::Sig
|
9
|
+
|
10
|
+
sig { override.void }
|
11
|
+
def report
|
12
|
+
[reclaim_warning, missing_acks_warning].compact.each do |warning|
|
13
|
+
io.puts
|
14
|
+
io.puts(warning)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
sig { returns(Configuration) }
|
21
|
+
def configuration
|
22
|
+
options[:distributed]
|
23
|
+
end
|
24
|
+
|
25
|
+
sig { returns(Coordinators::RedisCoordinator) }
|
26
|
+
def redis_coordinator
|
27
|
+
T.cast(configuration.coordinator, Coordinators::RedisCoordinator)
|
28
|
+
end
|
29
|
+
|
30
|
+
sig { returns(T.nilable(String)) }
|
31
|
+
def reclaim_warning
|
32
|
+
if redis_coordinator.reclaimed_tests.any?
|
33
|
+
<<~WARNING
|
34
|
+
WARNING: The following tests were reclaimed from another worker:
|
35
|
+
#{redis_coordinator.reclaimed_tests.map { |test| "- #{test.identifier}" }.join("\n")}
|
36
|
+
|
37
|
+
The original worker did not complete running this test in #{configuration.test_timeout}ms.
|
38
|
+
This either means that the worker unexpectedly went away, or that the test is too slow.
|
39
|
+
WARNING
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
sig { returns(T.nilable(String)) }
|
44
|
+
def missing_acks_warning
|
45
|
+
local_results = redis_coordinator.local_results
|
46
|
+
if local_results.acks < local_results.size
|
47
|
+
<<~WARNING
|
48
|
+
WARNING: This worker was not able to ack all the test it ran with the coordinator (#{local_results.acks}/#{local_results.size}).
|
49
|
+
|
50
|
+
This means that this worker took too long to report the status of one or more tests,
|
51
|
+
and these tests were claimed by other workers. As a result, the total number of
|
52
|
+
reported runs may be larger than the size of the test suite.
|
53
|
+
|
54
|
+
Make sure that all your tests complete within #{configuration.test_timeout}ms.
|
55
|
+
WARNING
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
class ResultAggregate < T::Struct
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
prop :runs, Integer, default: 0
|
10
|
+
prop :assertions, Integer, default: 0
|
11
|
+
prop :passes, Integer, default: 0
|
12
|
+
prop :failures, Integer, default: 0
|
13
|
+
prop :errors, Integer, default: 0
|
14
|
+
prop :skips, Integer, default: 0
|
15
|
+
prop :reruns, Integer, default: 0
|
16
|
+
prop :acks, Integer, default: 0
|
17
|
+
prop :size, Integer, default: 0
|
18
|
+
|
19
|
+
sig { params(result: Minitest::Result).void }
|
20
|
+
def update_with_result(result)
|
21
|
+
case (result_type = ResultType.of(result))
|
22
|
+
when ResultType::Passed then self.passes += 1
|
23
|
+
when ResultType::Failed then self.failures += 1
|
24
|
+
when ResultType::Error then self.errors += 1
|
25
|
+
when ResultType::Skipped then self.skips += 1
|
26
|
+
else T.absurd(result_type)
|
27
|
+
end
|
28
|
+
|
29
|
+
self.runs += 1
|
30
|
+
self.assertions += result.assertions
|
31
|
+
end
|
32
|
+
|
33
|
+
sig { returns(String) }
|
34
|
+
def to_s
|
35
|
+
str = +"#{runs} runs, #{assertions} assertions, #{passes} passes, #{failures} failures, #{errors} errors"
|
36
|
+
str << ", #{skips} skips" if skips > 0
|
37
|
+
str << ", #{reruns} re-runs" if reruns > 0
|
38
|
+
str
|
39
|
+
end
|
40
|
+
|
41
|
+
sig { returns(Integer) }
|
42
|
+
def unique_runs
|
43
|
+
runs - reruns
|
44
|
+
end
|
45
|
+
|
46
|
+
sig { returns(Integer) }
|
47
|
+
def reported_results
|
48
|
+
passes + failures + errors + skips
|
49
|
+
end
|
50
|
+
|
51
|
+
sig { returns(T::Boolean) }
|
52
|
+
def completed?
|
53
|
+
acks == size
|
54
|
+
end
|
55
|
+
|
56
|
+
sig { returns(T::Boolean) }
|
57
|
+
def valid?
|
58
|
+
unique_runs == reported_results
|
59
|
+
end
|
60
|
+
|
61
|
+
sig { returns(T::Boolean) }
|
62
|
+
def passed?
|
63
|
+
completed? && valid? && self.failures == 0 && self.errors == 0
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Minitest
|
5
|
+
module Distributed
|
6
|
+
class ResultType < T::Enum
|
7
|
+
enums do
|
8
|
+
Passed = new
|
9
|
+
Failed = new
|
10
|
+
Error = new
|
11
|
+
Skipped = new
|
12
|
+
end
|
13
|
+
|
14
|
+
sig { params(result: Minitest::Result).returns(ResultType) }
|
15
|
+
def self.of(result)
|
16
|
+
if result.passed?
|
17
|
+
Passed
|
18
|
+
elsif result.error?
|
19
|
+
Error
|
20
|
+
elsif result.skipped?
|
21
|
+
Skipped
|
22
|
+
else
|
23
|
+
Failed
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|