ci-queue 0.16.0 → 0.17.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -1
  3. data/Rakefile +1 -0
  4. data/bin/console +1 -0
  5. data/ci-queue.gemspec +2 -0
  6. data/exe/minitest-queue +1 -0
  7. data/exe/rspec-queue +1 -0
  8. data/lib/ci/queue.rb +3 -0
  9. data/lib/ci/queue/bisect.rb +1 -0
  10. data/lib/ci/queue/build_record.rb +1 -0
  11. data/lib/ci/queue/circuit_breaker.rb +39 -0
  12. data/lib/ci/queue/common.rb +3 -2
  13. data/lib/ci/queue/configuration.rb +25 -11
  14. data/lib/ci/queue/file.rb +1 -0
  15. data/lib/ci/queue/grind.rb +23 -0
  16. data/lib/ci/queue/output_helpers.rb +1 -0
  17. data/lib/ci/queue/redis.rb +6 -0
  18. data/lib/ci/queue/redis/base.rb +1 -0
  19. data/lib/ci/queue/redis/build_record.rb +4 -3
  20. data/lib/ci/queue/redis/grind.rb +17 -0
  21. data/lib/ci/queue/redis/grind_record.rb +66 -0
  22. data/lib/ci/queue/redis/grind_supervisor.rb +13 -0
  23. data/lib/ci/queue/redis/retry.rb +1 -0
  24. data/lib/ci/queue/redis/supervisor.rb +2 -1
  25. data/lib/ci/queue/redis/test_time_record.rb +66 -0
  26. data/lib/ci/queue/redis/worker.rb +2 -1
  27. data/lib/ci/queue/static.rb +3 -1
  28. data/lib/ci/queue/version.rb +3 -1
  29. data/lib/minitest/queue.rb +10 -4
  30. data/lib/minitest/queue/build_status_recorder.rb +1 -0
  31. data/lib/minitest/queue/build_status_reporter.rb +1 -0
  32. data/lib/minitest/queue/error_report.rb +13 -0
  33. data/lib/minitest/queue/failure_formatter.rb +4 -0
  34. data/lib/minitest/queue/grind_recorder.rb +68 -0
  35. data/lib/minitest/queue/grind_reporter.rb +74 -0
  36. data/lib/minitest/queue/junit_reporter.rb +6 -5
  37. data/lib/minitest/queue/local_requeue_reporter.rb +1 -0
  38. data/lib/minitest/queue/order_reporter.rb +1 -0
  39. data/lib/minitest/queue/runner.rb +156 -32
  40. data/lib/minitest/queue/statsd.rb +1 -0
  41. data/lib/minitest/queue/test_data.rb +138 -0
  42. data/lib/minitest/queue/test_data_reporter.rb +32 -0
  43. data/lib/minitest/queue/test_time_recorder.rb +17 -0
  44. data/lib/minitest/queue/test_time_reporter.rb +70 -0
  45. data/lib/minitest/reporters/bisect_reporter.rb +1 -0
  46. data/lib/minitest/reporters/statsd_reporter.rb +1 -0
  47. data/lib/rspec/queue.rb +15 -14
  48. data/lib/rspec/queue/build_status_recorder.rb +1 -0
  49. data/lib/rspec/queue/order_recorder.rb +20 -0
  50. metadata +29 -4
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'ci/queue/static'
2
3
 
3
4
  module CI
@@ -44,7 +45,7 @@ module CI
44
45
 
45
46
  def poll
46
47
  wait_for_master
47
- until shutdown_required? || config.circuit_breaker.open? || exhausted?
48
+ until shutdown_required? || config.circuit_breakers.any?(&:open?) || exhausted?
48
49
  if test = reserve
49
50
  yield index.fetch(test), @last_warning
50
51
  else
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CI
2
4
  module Queue
3
5
  class Static
@@ -48,7 +50,7 @@ module CI
48
50
  end
49
51
 
50
52
  def poll
51
- while !config.circuit_breaker.open? && test = @queue.shift
53
+ while !config.circuit_breakers.any?(&:open?) && test = @queue.shift
52
54
  yield index.fetch(test)
53
55
  end
54
56
  end
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CI
2
4
  module Queue
3
- VERSION = '0.16.0'
5
+ VERSION = '0.17.0'
4
6
  DEV_SCRIPTS_ROOT = ::File.expand_path('../../../../../redis', __FILE__)
5
7
  RELEASE_SCRIPTS_ROOT = ::File.expand_path('../redis', __FILE__)
6
8
  end
@@ -1,5 +1,5 @@
1
+ # frozen_string_literal: true
1
2
  require 'minitest'
2
- gem 'minitest-reporters', '~> 1.1'
3
3
  require 'minitest/reporters'
4
4
 
5
5
  require 'minitest/queue/failure_formatter'
@@ -9,6 +9,11 @@ require 'minitest/queue/build_status_recorder'
9
9
  require 'minitest/queue/build_status_reporter'
10
10
  require 'minitest/queue/order_reporter'
11
11
  require 'minitest/queue/junit_reporter'
12
+ require 'minitest/queue/test_data_reporter'
13
+ require 'minitest/queue/grind_recorder'
14
+ require 'minitest/queue/grind_reporter'
15
+ require 'minitest/queue/test_time_recorder'
16
+ require 'minitest/queue/test_time_reporter'
12
17
 
13
18
  module Minitest
14
19
  class Requeue < Skip
@@ -45,7 +50,7 @@ module Minitest
45
50
  end
46
51
 
47
52
  def result_label
48
- "Skipped"
53
+ "Flaked"
49
54
  end
50
55
 
51
56
  def backtrace
@@ -97,6 +102,7 @@ module Minitest
97
102
 
98
103
  module Queue
99
104
  class SingleExample
105
+
100
106
  def initialize(runnable, method_name)
101
107
  @runnable = runnable
102
108
  @method_name = method_name
@@ -144,8 +150,8 @@ module Minitest
144
150
  if queue
145
151
  run_from_queue(*args)
146
152
 
147
- if queue.config.circuit_breaker.open?
148
- STDERR.puts "This worker is exiting early because it encountered too many consecutive test failures, probably because of some corrupted state."
153
+ if queue.config.circuit_breakers.any?(&:open?)
154
+ STDERR.puts queue.config.circuit_breakers.map(&:message).join(' ').strip
149
155
  end
150
156
  else
151
157
  super
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'minitest/reporters'
2
3
 
3
4
  module Minitest
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Minitest
2
3
  module Queue
3
4
  class BuildStatusReporter < Minitest::Reporters::BaseReporter
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Minitest
2
3
  module Queue
3
4
  class ErrorReport
@@ -57,6 +58,18 @@ module Minitest
57
58
  @data[:test_and_module_name]
58
59
  end
59
60
 
61
+ def test_file
62
+ @data[:test_file]
63
+ end
64
+
65
+ def test_line
66
+ @data[:test_line]
67
+ end
68
+
69
+ def to_h
70
+ @data
71
+ end
72
+
60
73
  def to_s
61
74
  output
62
75
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'delegate'
2
3
  require 'ansi'
3
4
 
@@ -20,7 +21,10 @@ module Minitest
20
21
  end
21
22
 
22
23
  def to_h
24
+ test_file, test_line = test.source_location
23
25
  {
26
+ test_file: test_file,
27
+ test_line: test_line,
24
28
  test_and_module_name: "#{test.klass}##{test.name}",
25
29
  test_name: test.name,
26
30
  output: to_s,
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ module Minitest
3
+ module Queue
4
+ class GrindRecorder < Minitest::Reporters::BaseReporter
5
+
6
+ attr_accessor :test_count
7
+
8
+ def self.counters
9
+ @counters ||= {
10
+ 'failures' => 0,
11
+ 'errors' => 0,
12
+ 'skips' => 0,
13
+ 'test_count' => 0
14
+ }
15
+ end
16
+
17
+ class << self
18
+ attr_accessor :failure_formatter
19
+ end
20
+ self.failure_formatter = FailureFormatter
21
+
22
+ def initialize(build:, **options)
23
+ super(options)
24
+ @build = build
25
+ end
26
+
27
+ def record(test)
28
+ increment_counter(test)
29
+ record_test(test)
30
+ end
31
+
32
+ private
33
+
34
+ def record_test(test)
35
+ stats = self.class.counters
36
+ if (test.failure || test.error?) && !test.skipped?
37
+ build.record_error(dump(test), stats: stats)
38
+ else
39
+ build.record_success(stats: stats)
40
+ end
41
+ end
42
+
43
+ def increment_counter(test)
44
+ if test.skipped?
45
+ self.class.counters['skips'] += 1
46
+ elsif test.error?
47
+ self.class.counters['errors'] += 1
48
+ elsif test.failure
49
+ self.class.counters['failures'] += 1
50
+ end
51
+ self.class.counters['test_count'] += 1
52
+
53
+ key = "count##{test.klass}##{test.name}"
54
+
55
+ unless self.class.counters.key?(key)
56
+ self.class.counters[key] = 0
57
+ end
58
+ self.class.counters[key] += 1
59
+ end
60
+
61
+ def dump(test)
62
+ ErrorReport.new(self.class.failure_formatter.new(test).to_h).dump
63
+ end
64
+
65
+ attr_reader :build
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ require 'minitest/reporters'
3
+
4
+ module Minitest
5
+ module Queue
6
+ class GrindReporter < Minitest::Reporters::BaseReporter
7
+ include ::CI::Queue::OutputHelpers
8
+
9
+ def initialize(build:, **options)
10
+ @build = build
11
+ @success = true
12
+ super(options)
13
+ end
14
+
15
+ def report
16
+ puts '+++ Results'
17
+
18
+ if flaky_tests.empty?
19
+ puts green('all tests passed every time, grinding did not uncover any flakiness')
20
+ return
21
+ end
22
+ @success = false
23
+
24
+ flaky_tests.each do |name, errors|
25
+ total_runs = fetch_counts(name)
26
+ flakiness_percentage = (errors.count / total_runs) * 100
27
+
28
+ error_messages = errors.map do |message|
29
+ message.to_s.lines.map { |l| "\t#{l}"}.join
30
+ end.to_set.to_a.join("\n\n")
31
+
32
+ puts <<~EOS
33
+ #{red(name)}
34
+ Runs: #{total_runs.to_i}
35
+ Failures: #{errors.count}
36
+ Flakiness Percentage: #{flakiness_percentage.to_i}%
37
+ Errors:
38
+ #{error_messages}
39
+ EOS
40
+ end
41
+ end
42
+
43
+ def success?
44
+ @success
45
+ end
46
+
47
+ def flaky_tests
48
+ @flaky_tests ||= begin
49
+ flaky_tests = {}
50
+ build.error_reports.each do |error|
51
+ err = ErrorReport.load(error)
52
+ name = err.test_and_module_name
53
+ flaky_tests[name] ||= []
54
+ flaky_tests[name] << err
55
+ end
56
+ flaky_tests
57
+ end
58
+ end
59
+
60
+ def record(*)
61
+ raise NotImplementedError
62
+ end
63
+
64
+ def fetch_counts(test)
65
+ key = "count##{test}"
66
+ build.fetch_stats([key])[key]
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :build
72
+ end
73
+ end
74
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'minitest/reporters'
2
3
  require 'builder'
3
4
  require 'fileutils'
@@ -57,16 +58,16 @@ module Minitest
57
58
 
58
59
  def xml_message_for(test)
59
60
  xml = XmlMarkup.new(indent: 2, margin: 2)
60
- error = test.failure
61
+ failure = test.failure
61
62
 
62
63
  if test.skipped? && !test.flaked?
63
- xml.skipped(type: test.name)
64
+ xml.skipped(type: failure.error.class.name)
64
65
  elsif test.error?
65
- xml.error(type: test.name, message: xml.trunc!(error.message)) do
66
+ xml.error(type: failure.error.class.name, message: xml.trunc!(failure.message)) do
66
67
  xml.text!(message_for(test))
67
68
  end
68
- elsif test.failure
69
- xml.failure(type: test.name, message: xml.trunc!(error.message)) do
69
+ elsif failure
70
+ xml.failure(type: failure.error.class.name, message: xml.trunc!(failure.message)) do
70
71
  xml.text!(message_for(test))
71
72
  end
72
73
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'ci/queue/output_helpers'
2
3
  require 'minitest/reporters'
3
4
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'minitest/reporters'
2
3
 
3
4
  class Minitest::Queue::OrderReporter < Minitest::Reporters::BaseReporter
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  require 'optparse'
3
+ require 'json'
2
4
  require 'minitest/queue'
3
5
  require 'ci/queue'
4
6
  require 'digest/md5'
@@ -59,6 +61,7 @@ module Minitest
59
61
  LocalRequeueReporter.new,
60
62
  BuildStatusRecorder.new(build: queue.build),
61
63
  JUnitReporter.new,
64
+ TestDataReporter.new(namespace: queue_config&.namespace),
62
65
  OrderReporter.new(path: 'log/test_order.log'),
63
66
  ]
64
67
  if queue_config.statsd_endpoint
@@ -70,7 +73,7 @@ module Minitest
70
73
  trap('INT') { Minitest.queue.shutdown! }
71
74
 
72
75
  if queue.rescue_connection_errors { queue.exhausted? }
73
- puts green("All tests were ran already")
76
+ puts green('All tests were ran already')
74
77
  else
75
78
  load_tests
76
79
  populate_queue
@@ -78,6 +81,41 @@ module Minitest
78
81
  # Let minitest's at_exit hook trigger
79
82
  end
80
83
 
84
+ def grind_command
85
+ invalid_usage!('No list to grind provided') if grind_list.nil?
86
+ invalid_usage!('No grind count provided') if grind_count.nil?
87
+
88
+ set_load_path
89
+
90
+ queue_config.build_id = queue_config.build_id + '-grind'
91
+ queue_config.grind_count = grind_count
92
+
93
+ reporter_queue = CI::Queue::Redis::Grind.new(queue_url, queue_config)
94
+ test_time_record = CI::Queue::Redis::TestTimeRecord.new(queue_url, queue_config)
95
+
96
+ Minitest.queue = queue
97
+ reporters = [
98
+ GrindRecorder.new(build: reporter_queue.build),
99
+ TestDataReporter.new(namespace: queue_config&.namespace),
100
+ TestTimeRecorder.new(build: test_time_record)
101
+ ]
102
+ if queue_config.statsd_endpoint
103
+ reporters << Minitest::Reporters::StatsdReporter.new(statsd_endpoint: queue_config.statsd_endpoint)
104
+ end
105
+ Minitest.queue_reporters = reporters
106
+
107
+ trap('TERM') { Minitest.queue.shutdown! }
108
+ trap('INT') { Minitest.queue.shutdown! }
109
+
110
+ load_tests
111
+
112
+ @queue = CI::Queue::Grind.new(grind_list, queue_config)
113
+ Minitest.queue = queue
114
+ populate_queue
115
+
116
+ # Let minitest's at_exit hook trigger
117
+ end
118
+
81
119
  def bisect_command
82
120
  invalid_usage! "Missing the FAILING_TEST argument." unless queue_config.failing_test
83
121
 
@@ -144,14 +182,44 @@ module Minitest
144
182
  end
145
183
 
146
184
  reporter = BuildStatusReporter.new(build: supervisor.build)
185
+
186
+ if queue_config.failure_file
187
+ failures = reporter.error_reports.map(&:to_h).to_json
188
+ File.write(queue_config.failure_file, failures)
189
+ end
190
+
147
191
  reporter.report
148
192
  exit! reporter.success? ? 0 : 1
149
193
  end
150
194
 
195
+ def report_grind_command
196
+ queue_config.build_id = queue_config.build_id + '-grind'
197
+ @queue = CI::Queue::Redis::Grind.new(queue_url, queue_config)
198
+
199
+ supervisor = begin
200
+ queue.supervisor
201
+ rescue NotImplementedError => error
202
+ abort! error.message
203
+ end
204
+
205
+ grind_reporter = GrindReporter.new(build: supervisor.build)
206
+ grind_reporter.report
207
+
208
+ test_time_record = CI::Queue::Redis::TestTimeRecord.new(queue_url, queue_config)
209
+ test_time_reporter = Minitest::Queue::TestTimeReporter.new(
210
+ build: test_time_record,
211
+ limit: queue_config.max_test_duration,
212
+ percentile: queue_config.max_test_duration_percentile,
213
+ )
214
+ test_time_reporter.report
215
+
216
+ exit! grind_reporter.success? && test_time_reporter.success? ? 0 : 1
217
+ end
218
+
151
219
  private
152
220
 
153
221
  attr_reader :queue_config, :options, :command, :argv
154
- attr_accessor :queue, :queue_url, :load_paths
222
+ attr_accessor :queue, :queue_url, :grind_list, :grind_count, :load_paths
155
223
 
156
224
  def display_warnings(build)
157
225
  build.pop_warnings.each do |type, attributes|
@@ -160,7 +228,7 @@ module Minitest
160
228
  puts reopen_previous_step
161
229
  puts yellow(
162
230
  "[WARNING] #{attributes[:test]} was picked up by another worker because it didn't complete in the allocated #{attributes[:timeout]} seconds.\n" \
163
- "You may want to either optimize this test of bump ci-queue timeout.\n" \
231
+ "You may want to either optimize this test or bump ci-queue timeout.\n" \
164
232
  "It's also possible that the worker that was processing it was terminated without being able to report back.\n"
165
233
  )
166
234
  end
@@ -217,31 +285,47 @@ module Minitest
217
285
  opts.separator "GLOBAL OPTIONS"
218
286
 
219
287
 
220
- help = split_heredoc(<<-EOS)
288
+ help = <<~EOS
221
289
  URL of the queue, e.g. redis://example.com.
222
290
  Defaults to $CI_QUEUE_URL if set.
223
291
  EOS
224
292
  opts.separator ""
225
- opts.on('--queue URL', *help) do |url|
293
+ opts.on('--queue URL', help) do |url|
226
294
  self.queue_url = url
227
295
  end
228
296
 
229
- help = split_heredoc(<<-EOS)
297
+ help = <<~EOS
298
+ Path to the file that includes the list of tests to grind.
299
+ EOS
300
+ opts.separator ""
301
+ opts.on('--grind-list PATH', help) do |url|
302
+ self.grind_list = url
303
+ end
304
+
305
+ help = <<~EOS
306
+ Count defines how often each test in the grind list is going to be run.
307
+ EOS
308
+ opts.separator ""
309
+ opts.on('--grind-count COUNT', help) do |count|
310
+ self.grind_count = count.to_i
311
+ end
312
+
313
+ help = <<~EOS
230
314
  Unique identifier for the workload. All workers working on the same suite of tests must have the same build identifier.
231
315
  If the build is tried again, or another revision is built, this value must be different.
232
316
  It's automatically inferred on Buildkite, CircleCI, Heroku CI, and Travis.
233
317
  EOS
234
318
  opts.separator ""
235
- opts.on('--build BUILD_ID', *help) do |build_id|
319
+ opts.on('--build BUILD_ID', help) do |build_id|
236
320
  queue_config.build_id = build_id
237
321
  end
238
322
 
239
- help = split_heredoc(<<-EOS)
323
+ help = <<~EOS
240
324
  Optional. Sets a prefix for the build id in case a single CI build runs multiple independent test suites.
241
325
  Example: --namespace integration
242
326
  EOS
243
327
  opts.separator ""
244
- opts.on('--namespace NAMESPACE', *help) do |namespace|
328
+ opts.on('--namespace NAMESPACE', help) do |namespace|
245
329
  queue_config.namespace = namespace
246
330
  end
247
331
 
@@ -250,68 +334,112 @@ module Minitest
250
334
  opts.separator ""
251
335
  opts.separator " run [TEST_FILES...]: Participate in leader election, and then work off the test queue."
252
336
 
253
- help = split_heredoc(<<-EOS)
337
+ help = <<~EOS
254
338
  Specify a timeout after which if a test haven't completed, it will be picked up by another worker.
255
339
  It is very important to set this vlaue higher than the slowest test in the suite, otherwise performance will be impacted.
256
340
  Defaults to 30 seconds.
257
341
  EOS
258
342
  opts.separator ""
259
- opts.on('--timeout TIMEOUT', *help) do |timeout|
260
- queue_config.timeout = Float(timeout)
343
+ opts.on('--timeout TIMEOUT', Float, help) do |timeout|
344
+ queue_config.timeout = timeout
261
345
  end
262
346
 
263
- help = split_heredoc(<<-EOS)
347
+ help = <<~EOS
264
348
  Specify $LOAD_PATH directory, similar to Ruby's -I
265
349
  EOS
266
350
  opts.separator ""
267
- opts.on('-IPATHS', *help) do |paths|
351
+ opts.on('-IPATHS', help) do |paths|
268
352
  self.load_paths = paths
269
353
  end
270
354
 
271
- help = split_heredoc(<<-EOS)
355
+ help = <<~EOS
272
356
  Sepcify a seed used to shuffle the test suite.
273
357
  On Buildkite, CircleCI, Heroku CI, and Travis, the commit revision will be used by default.
274
358
  EOS
275
359
  opts.separator ""
276
- opts.on('--seed SEED', *help) do |seed|
360
+ opts.on('--seed SEED', help) do |seed|
277
361
  queue_config.seed = seed
278
362
  end
279
363
 
280
- help = split_heredoc(<<-EOS)
364
+ help = <<~EOS
281
365
  A unique identifier for this worker, It must be consistent to allow retries.
282
366
  If not specified, retries won't be available.
283
367
  It's automatically inferred on Buildkite, Heroku CI, and CircleCI.
284
368
  EOS
285
369
  opts.separator ""
286
- opts.on('--worker WORKER_ID', *help) do |worker_id|
370
+ opts.on('--worker WORKER_ID', help) do |worker_id|
287
371
  queue_config.worker_id = worker_id
288
372
  end
289
373
 
290
- help = split_heredoc(<<-EOS)
374
+ help = <<~EOS
291
375
  Defines how many time a single test can be requeued.
292
376
  Defaults to 0.
293
377
  EOS
294
378
  opts.separator ""
295
- opts.on('--max-requeues MAX', *help) do |max|
296
- queue_config.max_requeues = Integer(max)
379
+ opts.on('--max-requeues MAX', Integer, help) do |max|
380
+ queue_config.max_requeues = max
381
+ end
382
+
383
+ help = <<~EOS
384
+ Defines how long ci-queue should maximally run in seconds
385
+ Defaults to none.
386
+ EOS
387
+ opts.separator ""
388
+ opts.on('--max-duration SECONDS', Integer, help) do |max|
389
+ queue_config.max_duration = max
297
390
  end
298
391
 
299
- help = split_heredoc(<<-EOS)
392
+ help = <<~EOS
300
393
  Defines how many requeues can happen overall, based on the test suite size. e.g 0.05 for 5%.
301
394
  Defaults to 0.
302
395
  EOS
303
396
  opts.separator ""
304
- opts.on('--requeue-tolerance RATIO', *help) do |ratio|
305
- queue_config.requeue_tolerance = Float(ratio)
397
+ opts.on('--requeue-tolerance RATIO', Float, help) do |ratio|
398
+ queue_config.requeue_tolerance = ratio
399
+ end
400
+
401
+ help = <<~EOS
402
+ Defines a file where the test failures are written to in the json format.
403
+ Defaults to disabled.
404
+ EOS
405
+ opts.separator ""
406
+ opts.on('--failure-file FILE', help) do |file|
407
+ queue_config.failure_file = file
306
408
  end
307
409
 
308
- help = split_heredoc(<<-EOS)
410
+ help = <<~EOS
309
411
  Defines after how many consecutive failures the worker will be considered unhealthy and terminate itself.
310
412
  Defaults to disabled.
311
413
  EOS
312
414
  opts.separator ""
313
- opts.on('--max-consecutive-failures MAX', *help) do |max|
314
- queue_config.max_consecutive_failures = Integer(max)
415
+ opts.on('--max-consecutive-failures MAX', Integer, help) do |max|
416
+ queue_config.max_consecutive_failures = max
417
+ end
418
+
419
+ help = <<~EOS
420
+ Set the time limit of the execution time from grinds on a given test.
421
+ For example, when max-test-duration is set to 10 and
422
+ max-test-duration-percentile is set to 0.5, the test's median execution time during a grind must be
423
+ lower than 10 milliseconds.
424
+ The unit is milliseconds and decimal is allowed.
425
+ Defaults to disabled.
426
+ EOS
427
+ opts.on('--max-test-duration LIMIT_IN_MILLISECONDS', Float, help) do |limit|
428
+ queue_config.max_test_duration = limit
429
+ end
430
+
431
+ help = <<~EOS
432
+ The percentile for max-test-duration. For example, when max-test-duration is set to 10 and
433
+ max-test-duration-percentile is set to 0.5, the test's median execution time during a grind must be
434
+ lower than 10 milliseconds.
435
+ The percentile must be within the range 0 < percentile <= 1.
436
+ Defaults to 0.5 (50th percentile).
437
+ EOS
438
+ opts.on('--max-test-duration-percentile LIMIT_IN_MILLISECONDS', Float, help) do |percentile|
439
+ queue_config.max_test_duration_percentile = percentile
440
+ if queue_config.max_test_duration_percentile <= 0 || queue_config.max_test_duration_percentile > 1
441
+ raise OptionParser::ParseError.new("must be within range (0, 1]")
442
+ end
315
443
  end
316
444
 
317
445
  opts.separator ""
@@ -322,7 +450,7 @@ module Minitest
322
450
 
323
451
  opts.separator ""
324
452
  opts.separator " bisect: bisect a test suite to find global state leaks."
325
- help = split_heredoc(<<-EOS)
453
+ help = <<~EOS
326
454
  The identifier of the failing test.
327
455
  EOS
328
456
  opts.separator ""
@@ -332,10 +460,6 @@ module Minitest
332
460
  end
333
461
  end
334
462
 
335
- def split_heredoc(string)
336
- string.lines.map(&:strip)
337
- end
338
-
339
463
  def ordering_seed
340
464
  if queue_config.seed
341
465
  Random.new(Digest::MD5.hexdigest(queue_config.seed).to_i(16))