minitest-distributed 0.1.2 → 0.2.2

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/Gemfile +2 -2
  4. data/README.md +35 -13
  5. data/bin/setup +0 -2
  6. data/lib/minitest/distributed/configuration.rb +66 -4
  7. data/lib/minitest/distributed/coordinators/coordinator_interface.rb +3 -0
  8. data/lib/minitest/distributed/coordinators/memory_coordinator.rb +30 -9
  9. data/lib/minitest/distributed/coordinators/redis_coordinator.rb +259 -154
  10. data/lib/minitest/distributed/enqueued_runnable.rb +197 -40
  11. data/lib/minitest/distributed/filters/exclude_file_filter.rb +18 -0
  12. data/lib/minitest/distributed/filters/exclude_filter.rb +4 -4
  13. data/lib/minitest/distributed/filters/file_filter_base.rb +29 -0
  14. data/lib/minitest/distributed/filters/filter_interface.rb +3 -3
  15. data/lib/minitest/distributed/filters/include_file_filter.rb +18 -0
  16. data/lib/minitest/distributed/filters/include_filter.rb +4 -4
  17. data/lib/minitest/distributed/reporters/distributed_progress_reporter.rb +13 -5
  18. data/lib/minitest/distributed/reporters/distributed_summary_reporter.rb +49 -10
  19. data/lib/minitest/distributed/reporters/junitxml_reporter.rb +150 -0
  20. data/lib/minitest/distributed/reporters/redis_coordinator_warnings_reporter.rb +11 -16
  21. data/lib/minitest/distributed/result_aggregate.rb +38 -9
  22. data/lib/minitest/distributed/result_type.rb +76 -2
  23. data/lib/minitest/distributed/test_selector.rb +12 -6
  24. data/lib/minitest/distributed/version.rb +1 -1
  25. data/lib/minitest/distributed.rb +3 -0
  26. data/lib/minitest/distributed_plugin.rb +1 -25
  27. data/lib/minitest/junitxml_plugin.rb +21 -0
  28. data/sorbet/rbi/minitest.rbi +29 -11
  29. data/sorbet/rbi/redis.rbi +19 -4
  30. metadata +11 -7
  31. data/.travis.yml +0 -6
@@ -3,86 +3,243 @@
3
3
 
4
4
  module Minitest
5
5
  module Distributed
6
+ class PendingExecution < T::Struct
7
+ extend T::Sig
8
+
9
+ const :worker_id, String
10
+ const :entry_id, String
11
+ const :elapsed_time_ms, Integer
12
+ const :attempt, Integer
13
+
14
+ sig { returns(String) }
15
+ def attempt_id
16
+ "#{entry_id}/#{attempt}"
17
+ end
18
+
19
+ sig { params(xpending_result: T::Hash[String, T.untyped]).returns(T.attached_class) }
20
+ def self.from_xpending(xpending_result)
21
+ new(
22
+ worker_id: xpending_result.fetch('consumer'),
23
+ entry_id: xpending_result.fetch('entry_id'),
24
+ elapsed_time_ms: xpending_result.fetch('elapsed'),
25
+ attempt: xpending_result.fetch('count'),
26
+ )
27
+ end
28
+ end
29
+
30
+ # This module defines some helper methods to deal with Minitest::Runnable
31
+ module DefinedRunnable
32
+ extend T::Sig
33
+
34
+ sig { params(name: String).returns(T.class_of(Minitest::Runnable)) }
35
+ def self.find_class(name)
36
+ name.split('::')
37
+ .reduce(Object) { |ns, const| ns.const_get(const) } # rubocop:disable Sorbet/ConstantsFromStrings
38
+ end
39
+
40
+ sig { params(runnable: Minitest::Runnable).returns(String) }
41
+ def self.identifier(runnable)
42
+ "#{T.must(runnable.class.name)}##{runnable.name}"
43
+ end
44
+
45
+ sig { params(identifier: String).returns(Minitest::Runnable) }
46
+ def self.from_identifier(identifier)
47
+ class_name, method_name = identifier.split('#', 2)
48
+ find_class(T.must(class_name)).new(T.must(method_name))
49
+ end
50
+ end
51
+
6
52
  class EnqueuedRunnable < T::Struct
7
- class << self
53
+ class Result < T::Struct
54
+ class Commit
55
+ extend T::Sig
56
+
57
+ sig { params(block: T.proc.returns(T::Boolean)).void }
58
+ def initialize(&block)
59
+ @block = block
60
+ end
61
+
62
+ sig { returns(T::Boolean) }
63
+ def success?
64
+ @success = T.let(@success, T.nilable(T::Boolean))
65
+ @success ||= @block.call
66
+ end
67
+
68
+ sig { returns(T::Boolean) }
69
+ def failure?
70
+ !success?
71
+ end
72
+
73
+ sig { returns(Commit) }
74
+ def self.success
75
+ @success = T.let(@success, T.nilable(Commit))
76
+ @success ||= new { true }
77
+ end
78
+
79
+ sig { returns(Commit) }
80
+ def self.failure
81
+ @failure = T.let(@failure, T.nilable(Commit))
82
+ @failure ||= new { false }
83
+ end
84
+ end
85
+
8
86
  extend T::Sig
9
87
 
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
- )
88
+ const :enqueued_runnable, EnqueuedRunnable
89
+ const :initial_result, Minitest::Result
90
+ const :commit, Commit
91
+
92
+ sig { returns(String) }
93
+ def entry_id
94
+ enqueued_runnable.entry_id
17
95
  end
18
96
 
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
- )
97
+ sig { returns(T::Boolean) }
98
+ def final?
99
+ !requeue?
25
100
  end
26
101
 
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)
102
+ sig { returns(T::Boolean) }
103
+ def requeue?
104
+ ResultType.of(initial_result) == ResultType::Requeued
30
105
  end
31
106
 
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|
107
+ sig { returns(Minitest::Result) }
108
+ def committed_result
109
+ @committed_result = T.let(@committed_result, T.nilable(Minitest::Result))
110
+ @committed_result ||= if final? && commit.failure?
111
+ # If a runnable result is final, but the acked failed, we will discard the result.
112
+ Minitest::Discard.wrap(initial_result, test_timeout_seconds: enqueued_runnable.test_timeout_seconds)
113
+ else
114
+ initial_result
115
+ end
116
+ end
117
+ end
118
+
119
+ class << self
120
+ extend T::Sig
121
+
122
+ sig do
123
+ params(
124
+ claims: T::Array[[String, T::Hash[String, String]]],
125
+ pending_messages: T::Hash[String, PendingExecution],
126
+ configuration: Configuration,
127
+ ).returns(T::Array[T.attached_class])
128
+ end
129
+ def from_redis_stream_claim(claims, pending_messages = {}, configuration:)
130
+ claims.map do |entry_id, runnable_method_info|
131
+ # `attempt` will be set to the current attempt of a different worker that has timed out.
132
+ # The attempt we are going to try will be the next one, so add one.
133
+ attempt = pending_messages.key?(entry_id) ? pending_messages.fetch(entry_id).attempt + 1 : 1
134
+
35
135
  new(
36
136
  class_name: runnable_method_info.fetch('class_name'),
37
137
  method_name: runnable_method_info.fetch('method_name'),
38
- execution_id: id,
138
+ entry_id: entry_id,
139
+ attempt: attempt,
140
+ max_attempts: configuration.max_attempts,
141
+ test_timeout_seconds: configuration.test_timeout_seconds,
39
142
  )
40
143
  end
41
144
  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
145
  end
49
146
 
50
147
  extend T::Sig
51
148
 
52
149
  const :class_name, String
53
150
  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
151
+ const :entry_id, String, factory: -> { SecureRandom.uuid }, dont_store: true
152
+ const :attempt, Integer, default: 1, dont_store: true
153
+ const :max_attempts, Integer, dont_store: true
154
+ const :test_timeout_seconds, Float, dont_store: true
59
155
 
60
156
  sig { returns(String) }
61
157
  def identifier
62
158
  "#{class_name}##{method_name}"
63
159
  end
64
160
 
161
+ sig { returns(String) }
162
+ def attempt_id
163
+ "#{entry_id}/#{attempt}"
164
+ end
165
+
65
166
  sig { returns(T.class_of(Minitest::Runnable)) }
66
167
  def runnable_class
67
- self.class.find_runnable_class(class_name)
168
+ DefinedRunnable.find_class(class_name)
68
169
  end
69
170
 
70
171
  sig { returns(Minitest::Runnable) }
71
- def runnable
172
+ def instantiate_runnable
72
173
  runnable_class.new(method_name)
73
174
  end
74
175
 
176
+ sig { returns(T::Boolean) }
177
+ def attempts_exhausted?
178
+ attempt > max_attempts
179
+ end
180
+
181
+ sig { returns(T::Boolean) }
182
+ def final_attempt?
183
+ attempt == max_attempts
184
+ end
185
+
186
+ sig { returns(Minitest::Result) }
187
+ def attempts_exhausted_result
188
+ assertion = Minitest::AttemptsExhausted.new(<<~EOM.chomp)
189
+ This test takes too long to run (> #{test_timeout_seconds}s).
190
+
191
+ We have tried running this test #{max_attempts} on different workers, but every time the worker has not reported back a result within #{test_timeout_seconds}s.
192
+ Try to make the test faster, or increase the test timeout.
193
+ EOM
194
+ assertion.set_backtrace(caller)
195
+
196
+ runnable = instantiate_runnable
197
+ runnable.time = 0.0
198
+ runnable.failures = [assertion]
199
+
200
+ Minitest::Result.from(runnable)
201
+ end
202
+
203
+ sig do
204
+ params(
205
+ initial_result: Minitest::Result,
206
+ block: T.proc.params(arg0: Minitest::Result).returns(EnqueuedRunnable::Result::Commit)
207
+ ).returns(EnqueuedRunnable::Result)
208
+ end
209
+ def commit_result(initial_result, &block)
210
+ EnqueuedRunnable::Result.new(
211
+ enqueued_runnable: self,
212
+ initial_result: initial_result,
213
+ commit: block.call(initial_result),
214
+ )
215
+ end
216
+
75
217
  sig { returns(Minitest::Result) }
76
218
  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)
219
+ if attempts_exhausted?
220
+ attempts_exhausted_result
82
221
  else
83
- Minitest.run_one_method(runnable_class, method_name)
222
+ result = Minitest.run_one_method(runnable_class, method_name)
223
+ result_type = ResultType.of(result)
224
+ if (result_type == ResultType::Error || result_type == ResultType::Failed) && !final_attempt?
225
+ Minitest::Requeue.wrap(result, attempt: attempt, max_attempts: max_attempts)
226
+ else
227
+ result
228
+ end
84
229
  end
85
230
  end
231
+
232
+ sig { returns(T.self_type) }
233
+ def next_attempt
234
+ self.class.new(
235
+ class_name: class_name,
236
+ method_name: method_name,
237
+ entry_id: entry_id,
238
+ attempt: attempt + 1,
239
+ max_attempts: max_attempts,
240
+ test_timeout_seconds: test_timeout_seconds,
241
+ )
242
+ end
86
243
  end
87
244
  end
88
245
  end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Minitest
5
+ module Distributed
6
+ module Filters
7
+ class ExcludeFileFilter < FileFilterBase
8
+ extend T::Sig
9
+ include FilterInterface
10
+
11
+ sig { override.params(runnable: Minitest::Runnable).returns(T::Array[Runnable]) }
12
+ def call(runnable)
13
+ tests.include?(DefinedRunnable.identifier(runnable)) ? [] : [runnable]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -19,13 +19,13 @@ module Minitest
19
19
  end
20
20
  end
21
21
 
22
- sig { override.params(enqueued_runnable: EnqueuedRunnable).returns(T::Array[EnqueuedRunnable]) }
23
- def call(enqueued_runnable)
22
+ sig { override.params(runnable: Minitest::Runnable).returns(T::Array[Runnable]) }
23
+ def call(runnable)
24
24
  # rubocop:disable Style/CaseEquality
25
- if filter === enqueued_runnable.method_name || filter === enqueued_runnable.identifier
25
+ if filter === runnable.name || filter === DefinedRunnable.identifier(runnable)
26
26
  []
27
27
  else
28
- [enqueued_runnable]
28
+ [runnable]
29
29
  end
30
30
  # rubocop:enable Style/CaseEquality
31
31
  end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Minitest
5
+ module Distributed
6
+ module Filters
7
+ class FileFilterBase
8
+ extend T::Sig
9
+
10
+ sig { returns(Pathname) }
11
+ attr_reader :file
12
+
13
+ sig { params(file: Pathname).void }
14
+ def initialize(file)
15
+ @file = file
16
+ @tests = T.let(nil, T.nilable(T::Set[String]))
17
+ end
18
+
19
+ sig { returns(T::Set[String]) }
20
+ def tests
21
+ @tests ||= begin
22
+ tests = File.readlines(@file, chomp: true)
23
+ Set.new(tests)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -9,7 +9,7 @@ module Minitest
9
9
  # array of runnables.
10
10
  #
11
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.
12
+ # - If it returns a single element array with the passed ion runnable to make no changes.
13
13
  # - It can return an array of enumerables to expand the number of runnables in this test run,
14
14
  # We use this for grinding tests, for instance.
15
15
  module FilterInterface
@@ -17,8 +17,8 @@ module Minitest
17
17
  extend T::Helpers
18
18
  interface!
19
19
 
20
- sig { abstract.params(runnable_method: EnqueuedRunnable).returns(T::Array[EnqueuedRunnable]) }
21
- def call(runnable_method); end
20
+ sig { abstract.params(runnable: Minitest::Runnable).returns(T::Array[Minitest::Runnable]) }
21
+ def call(runnable); end
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Minitest
5
+ module Distributed
6
+ module Filters
7
+ class IncludeFileFilter < FileFilterBase
8
+ extend T::Sig
9
+ include FilterInterface
10
+
11
+ sig { override.params(runnable: Minitest::Runnable).returns(T::Array[Runnable]) }
12
+ def call(runnable)
13
+ tests.include?(DefinedRunnable.identifier(runnable)) ? [runnable] : []
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -19,11 +19,11 @@ module Minitest
19
19
  end
20
20
  end
21
21
 
22
- sig { override.params(enqueued_runnable: EnqueuedRunnable).returns(T::Array[EnqueuedRunnable]) }
23
- def call(enqueued_runnable)
22
+ sig { override.params(runnable: Minitest::Runnable).returns(T::Array[Minitest::Runnable]) }
23
+ def call(runnable)
24
24
  # rubocop:disable Style/CaseEquality
25
- if filter === enqueued_runnable.method_name || filter === enqueued_runnable.identifier
26
- [enqueued_runnable]
25
+ if filter === runnable.name || filter === DefinedRunnable.identifier(runnable)
26
+ [runnable]
27
27
  else
28
28
  []
29
29
  end
@@ -20,6 +20,7 @@ module Minitest
20
20
  end
21
21
  @coordinator = T.let(options[:distributed].coordinator, Coordinators::CoordinatorInterface)
22
22
  @window_line_width = T.let(nil, T.nilable(Integer))
23
+ @show_progress = T.let(options[:distributed].progress, T::Boolean)
23
24
  end
24
25
 
25
26
  sig { override.void }
@@ -37,7 +38,7 @@ module Minitest
37
38
 
38
39
  sig { override.params(klass: T.class_of(Runnable), name: String).void }
39
40
  def prerecord(klass, name)
40
- if io.tty?
41
+ if show_progress?
41
42
  clear_current_line
42
43
  io.print("[#{results.acks}/#{results.size}] #{klass}##{name}".slice(0...window_line_width))
43
44
  end
@@ -45,14 +46,14 @@ module Minitest
45
46
 
46
47
  sig { override.params(result: Minitest::Result).void }
47
48
  def record(result)
48
- clear_current_line if io.tty?
49
+ clear_current_line if show_progress?
49
50
 
50
51
  case (result_type = ResultType.of(result))
51
52
  when ResultType::Passed
52
53
  # TODO: warn for tests that are slower than the test timeout.
53
- when ResultType::Skipped
54
+ when ResultType::Skipped, ResultType::Discarded
54
55
  io.puts("#{result}\n") if options[:verbose]
55
- when ResultType::Error, ResultType::Failed
56
+ when ResultType::Error, ResultType::Failed, ResultType::Requeued
56
57
  io.puts("#{result}\n")
57
58
  else
58
59
  T.absurd(result_type)
@@ -61,11 +62,16 @@ module Minitest
61
62
 
62
63
  sig { override.void }
63
64
  def report
64
- clear_current_line if io.tty?
65
+ clear_current_line if show_progress?
65
66
  end
66
67
 
67
68
  private
68
69
 
70
+ sig { returns(T::Boolean) }
71
+ def show_progress?
72
+ @show_progress
73
+ end
74
+
69
75
  sig { void }
70
76
  def clear_current_line
71
77
  io.print("\r" + (' ' * window_line_width) + "\r")
@@ -76,6 +82,8 @@ module Minitest
76
82
  @window_line_width ||= begin
77
83
  _height, width = io.winsize
78
84
  width > 0 ? width : 80
85
+ rescue Errno::ENOTTY
86
+ 80
79
87
  end
80
88
  end
81
89
 
@@ -7,14 +7,10 @@ module Minitest
7
7
  class DistributedSummaryReporter < Minitest::Reporter
8
8
  extend T::Sig
9
9
 
10
- sig { returns(Coordinators::CoordinatorInterface) }
11
- attr_reader :coordinator
12
-
13
10
  sig { params(io: IO, options: T::Hash[Symbol, T.untyped]).void }
14
11
  def initialize(io, options)
15
12
  super
16
13
  io.sync = true
17
- @coordinator = T.let(options[:distributed].coordinator, Coordinators::CoordinatorInterface)
18
14
  @start_time = T.let(0.0, Float)
19
15
  end
20
16
 
@@ -26,21 +22,64 @@ module Minitest
26
22
 
27
23
  sig { override.void }
28
24
  def report
29
- duration = format("(in %0.3fs)", Minitest.clock_time - @start_time)
25
+ print_discard_warning if local_results.discards > 0
26
+
27
+ if configuration.coordinator.aborted?
28
+ io.puts("Cannot retry a run that was cut short during the previous attempt.")
29
+ io.puts
30
+ elsif combined_results.abort?
31
+ io.puts("The run was cut short after reaching the limit of #{configuration.max_failures} test failures.")
32
+ io.puts
33
+ end
30
34
 
31
- local_results = coordinator.local_results
32
- combined_results = coordinator.combined_results
35
+ formatted_duration = format("(in %0.3fs)", Minitest.clock_time - @start_time)
33
36
  if combined_results == local_results
34
- io.puts("Results: #{combined_results} #{duration}")
37
+ io.puts("Results: #{combined_results} #{formatted_duration}")
35
38
  else
36
- io.puts("This worker: #{local_results} #{duration}")
39
+ io.puts("This worker: #{local_results} #{formatted_duration}")
37
40
  io.puts("Combined results: #{combined_results}")
38
41
  end
39
42
  end
40
43
 
41
44
  sig { override.returns(T::Boolean) }
42
45
  def passed?
43
- coordinator.combined_results.passed?
46
+ return false if configuration.coordinator.aborted?
47
+
48
+ # Generally, we want the workers to fail that had at least one failed or errored
49
+ # test. We have to trust that another worker will fail (and fail the build) if it
50
+ # encountered a failed test. We trust that the other worker will do this correctly,
51
+ # but we do verify that the statistics for the complete run are valid,
52
+ # to have some protection against unknown edge cases and bugs.
53
+ local_results.passed? && combined_results.valid?
54
+ end
55
+
56
+ protected
57
+
58
+ sig { void }
59
+ def print_discard_warning
60
+ io.puts(<<~WARNING)
61
+ WARNING: This worker was not able to ack all the tests it ran with the coordinator,
62
+ and had to discard the results of those tests. This means that some of your tests may
63
+ take too long to run. Make sure that all your tests complete well within #{configuration.test_timeout_seconds}s.
64
+
65
+ WARNING
66
+ end
67
+
68
+ sig { returns(ResultAggregate) }
69
+ def local_results
70
+ @local_results = T.let(@local_results, T.nilable(ResultAggregate))
71
+ @local_results ||= configuration.coordinator.local_results
72
+ end
73
+
74
+ sig { returns(ResultAggregate) }
75
+ def combined_results
76
+ @combined_results = T.let(@combined_results, T.nilable(ResultAggregate))
77
+ @combined_results ||= configuration.coordinator.combined_results
78
+ end
79
+
80
+ sig { returns(Configuration) }
81
+ def configuration
82
+ T.let(options[:distributed], Configuration)
44
83
  end
45
84
  end
46
85
  end