test-queue 0.2.13 → 0.3.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -0
  3. data/.travis.yml +18 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile-cucumber1-3 +4 -0
  6. data/Gemfile-cucumber1-3.lock +33 -0
  7. data/Gemfile-cucumber2-4 +4 -0
  8. data/Gemfile-cucumber2-4.lock +37 -0
  9. data/Gemfile-minitest4.lock +4 -23
  10. data/Gemfile-minitest5 +3 -0
  11. data/Gemfile-minitest5.lock +19 -0
  12. data/Gemfile-rspec2-1 +3 -0
  13. data/Gemfile-rspec2-1.lock +27 -0
  14. data/Gemfile-rspec3-0.lock +4 -13
  15. data/Gemfile-rspec3-1.lock +4 -13
  16. data/Gemfile-rspec3-2.lock +4 -13
  17. data/Gemfile-testunit.lock +4 -27
  18. data/Gemfile.lock +4 -1
  19. data/README.md +4 -0
  20. data/bin/minitest-queue +0 -1
  21. data/bin/testunit-queue +0 -1
  22. data/lib/test_queue/iterator.rb +41 -10
  23. data/lib/test_queue/runner.rb +167 -58
  24. data/lib/test_queue/runner/cucumber.rb +81 -12
  25. data/lib/test_queue/runner/minitest.rb +0 -4
  26. data/lib/test_queue/runner/minitest4.rb +25 -2
  27. data/lib/test_queue/runner/minitest5.rb +36 -11
  28. data/lib/test_queue/runner/rspec.rb +55 -7
  29. data/lib/test_queue/runner/rspec2.rb +11 -8
  30. data/lib/test_queue/runner/rspec3.rb +10 -7
  31. data/lib/test_queue/runner/sample.rb +0 -2
  32. data/lib/test_queue/runner/testunit.rb +25 -7
  33. data/lib/test_queue/stats.rb +95 -0
  34. data/lib/test_queue/test_framework.rb +29 -0
  35. data/script/bootstrap +12 -0
  36. data/script/cibuild +19 -0
  37. data/script/spec +7 -0
  38. data/spec/stats_spec.rb +76 -0
  39. data/test-queue.gemspec +1 -4
  40. data/test/cucumber.bats +57 -0
  41. data/test/minitest4.bats +34 -0
  42. data/test/minitest5.bats +111 -0
  43. data/test/rspec.bats +38 -0
  44. data/{features → test/samples/features}/bad.feature +0 -0
  45. data/{features → test/samples/features}/sample.feature +0 -0
  46. data/{features → test/samples/features}/sample2.feature +0 -0
  47. data/{features → test/samples/features}/step_definitions/common.rb +5 -1
  48. data/test/{sample_minispec.rb → samples/sample_minispec.rb} +6 -0
  49. data/test/{sample_minitest4.rb → samples/sample_minitest4.rb} +5 -3
  50. data/test/{sample_minitest5.rb → samples/sample_minitest5.rb} +5 -3
  51. data/test/{sample_spec.rb → samples/sample_spec.rb} +5 -3
  52. data/test/samples/sample_split_spec.rb +17 -0
  53. data/test/{sample_testunit.rb → samples/sample_testunit.rb} +5 -3
  54. data/test/testlib.bash +81 -0
  55. data/test/testunit.bats +20 -0
  56. metadata +40 -60
  57. data/test-multi.sh +0 -8
  58. data/test.sh +0 -23
@@ -1,19 +1,25 @@
1
+ require 'set'
1
2
  require 'socket'
2
3
  require 'fileutils'
3
4
  require 'securerandom'
5
+ require 'test_queue/stats'
6
+ require 'test_queue/test_framework'
4
7
 
5
8
  module TestQueue
6
9
  class Worker
7
- attr_accessor :pid, :status, :output, :stats, :num, :host
10
+ attr_accessor :pid, :status, :output, :num, :host
8
11
  attr_accessor :start_time, :end_time
9
12
  attr_accessor :summary, :failure_output
10
13
 
14
+ # Array of TestQueue::Stats::Suite recording all the suites this worker ran.
15
+ attr_reader :suites
16
+
11
17
  def initialize(pid, num)
12
18
  @pid = pid
13
19
  @num = num
14
20
  @start_time = Time.now
15
21
  @output = ''
16
- @stats = {}
22
+ @suites = []
17
23
  end
18
24
 
19
25
  def lines
@@ -22,21 +28,40 @@ module TestQueue
22
28
  end
23
29
 
24
30
  class Runner
25
- attr_accessor :concurrency
31
+ attr_accessor :concurrency, :exit_when_done
32
+ attr_reader :stats
33
+
34
+ def initialize(test_framework, concurrency=nil, socket=nil, relay=nil)
35
+ @test_framework = test_framework
36
+ @stats = Stats.new(stats_file)
37
+
38
+ if ENV['TEST_QUEUE_EARLY_FAILURE_LIMIT']
39
+ begin
40
+ @early_failure_limit = Integer(ENV['TEST_QUEUE_EARLY_FAILURE_LIMIT'])
41
+ rescue ArgumentError
42
+ raise ArgumentError, 'TEST_QUEUE_EARLY_FAILURE_LIMIT could not be parsed as an integer'
43
+ end
44
+ end
45
+
46
+ @procline = $0
47
+
48
+ @whitelist = Set.new
26
49
 
27
- def initialize(queue, concurrency=nil, socket=nil, relay=nil)
28
- raise ArgumentError, 'array required' unless Array === queue
50
+ all_files = @test_framework.all_suite_files.to_set
51
+ @queue = @stats.all_suites
52
+ .select { |suite| all_files.include?(suite.path) }
53
+ .sort_by { |suite| -suite.duration }
54
+ .map { |suite| [suite.name, suite.path] }
29
55
 
30
56
  if forced = ENV['TEST_QUEUE_FORCE']
31
57
  forced = forced.split(/\s*,\s*/)
32
- whitelist = Set.new(forced)
33
- queue = queue.select{ |s| whitelist.include?(s.to_s) }
34
- queue.sort_by!{ |s| forced.index(s.to_s) }
58
+ @whitelist.merge(forced)
59
+ @queue.select! { |suite_name, path| @whitelist.include?(suite_name) }
60
+ @queue.sort_by! { |suite_name, path| forced.index(suite_name) }
35
61
  end
36
62
 
37
- @procline = $0
38
- @queue = queue
39
- @suites = queue.inject(Hash.new){ |hash, suite| hash.update suite.to_s => suite }
63
+ @whitelist.freeze
64
+ @original_queue = Set.new(@queue).freeze
40
65
 
41
66
  @workers = {}
42
67
  @completed = []
@@ -51,6 +76,9 @@ module TestQueue
51
76
  else
52
77
  2
53
78
  end
79
+ unless @concurrency > 0
80
+ raise ArgumentError, "Worker count (#{@concurrency}) must be greater than 0"
81
+ end
54
82
 
55
83
  @slave_connection_timeout =
56
84
  (ENV['TEST_QUEUE_RELAY_TIMEOUT'] && ENV['TEST_QUEUE_RELAY_TIMEOUT'].to_i) ||
@@ -75,26 +103,27 @@ module TestQueue
75
103
  elsif @relay
76
104
  @queue = []
77
105
  end
78
- end
79
106
 
80
- def stats
81
- @stats ||=
82
- if File.exists?(file = stats_file)
83
- Marshal.load(IO.binread(file)) || {}
84
- else
85
- {}
86
- end
107
+ @exit_when_done = true
87
108
  end
88
109
 
110
+ # Run the tests.
111
+ #
112
+ # If exit_when_done is true, exit! will be called before this method
113
+ # completes. If exit_when_done is false, this method will return an Integer
114
+ # number of failures.
89
115
  def execute
90
116
  $stdout.sync = $stderr.sync = true
91
117
  @start_time = Time.now
92
118
 
93
- @concurrency > 0 ?
94
- execute_parallel :
95
- execute_sequential
96
- ensure
97
- summarize_internal unless $!
119
+ execute_internal
120
+ exitstatus = summarize_internal
121
+
122
+ if exit_when_done
123
+ exit! exitstatus
124
+ else
125
+ exitstatus
126
+ end
98
127
  end
99
128
 
100
129
  def summarize_internal
@@ -104,13 +133,16 @@ module TestQueue
104
133
 
105
134
  @failures = ''
106
135
  @completed.each do |worker|
136
+ @stats.record_suites(worker.suites)
137
+
107
138
  summarize_worker(worker)
139
+
108
140
  @failures << worker.failure_output if worker.failure_output
109
141
 
110
142
  puts " [%2d] %60s %4d suites in %.4fs (pid %d exit %d%s)" % [
111
143
  worker.num,
112
144
  worker.summary,
113
- worker.stats.size,
145
+ worker.suites.size,
114
146
  worker.end_time - worker.start_time,
115
147
  worker.pid,
116
148
  worker.status.exitstatus,
@@ -127,17 +159,13 @@ module TestQueue
127
159
 
128
160
  puts
129
161
 
130
- if @stats
131
- File.open(stats_file, 'wb') do |f|
132
- f.write Marshal.dump(stats)
133
- end
134
- end
162
+ @stats.save
135
163
 
136
164
  summarize
137
165
 
138
166
  estatus = @completed.inject(0){ |s, worker| s + worker.status.exitstatus }
139
167
  estatus = 255 if estatus > 255
140
- exit!(estatus)
168
+ estatus
141
169
  end
142
170
 
143
171
  def summarize
@@ -148,27 +176,18 @@ module TestQueue
148
176
  '.test_queue_stats'
149
177
  end
150
178
 
151
- def execute_sequential
152
- exit! run_worker(@queue)
153
- end
154
-
155
- def execute_parallel
179
+ def execute_internal
156
180
  start_master
157
181
  prepare(@concurrency)
158
182
  @prepared_time = Time.now
159
183
  start_relay if relay?
184
+ discover_suites
160
185
  spawn_workers
161
186
  distribute_queue
162
187
  ensure
163
188
  stop_master
164
189
 
165
- @workers.each do |pid, worker|
166
- Process.kill 'KILL', pid
167
- end
168
-
169
- until @workers.empty?
170
- reap_worker
171
- end
190
+ kill_workers
172
191
  end
173
192
 
174
193
  def start_master
@@ -223,7 +242,7 @@ module TestQueue
223
242
  pid = fork do
224
243
  @server.close if @server
225
244
 
226
- iterator = Iterator.new(relay?? @relay : @socket, @suites, method(:around_filter))
245
+ iterator = Iterator.new(@test_framework, relay?? @relay : @socket, method(:around_filter), early_failure_limit: @early_failure_limit)
227
246
  after_fork_internal(num, iterator)
228
247
  ret = run_worker(iterator) || 0
229
248
  cleanup_worker
@@ -234,6 +253,37 @@ module TestQueue
234
253
  end
235
254
  end
236
255
 
256
+ def discover_suites
257
+ return if relay?
258
+ @discovering_suites_pid = fork do
259
+ @test_framework.all_suite_files.each do |path|
260
+ @test_framework.suites_from_file(path).each do |suite_name, suite|
261
+ @server.connect_address.connect do |sock|
262
+ sock.puts("NEW SUITE #{Marshal.dump([suite_name, path])}")
263
+ end
264
+ end
265
+ end
266
+
267
+ Kernel.exit! 0
268
+ end
269
+ end
270
+
271
+ def enqueue_discovered_suite(suite_name, path)
272
+ if @whitelist.any? && !@whitelist.include?(suite_name)
273
+ return
274
+ end
275
+
276
+ if @original_queue.include?([suite_name, path])
277
+ # This suite was already added to the queue some other way.
278
+ return
279
+ end
280
+
281
+ # We don't know how long new suites will take to run, so we put them at
282
+ # the front of the queue. It's better to run a fast suite early than to
283
+ # run a slow suite late.
284
+ @queue.unshift [suite_name, path]
285
+ end
286
+
237
287
  def after_fork_internal(num, iterator)
238
288
  srand
239
289
 
@@ -268,8 +318,7 @@ module TestQueue
268
318
  # Entry point for internal runner implementations. The iterator will yield
269
319
  # jobs from the shared queue on the master.
270
320
  #
271
- # Returns nothing. exits 0 on success.
272
- # exits N on error, where N is the number of failures.
321
+ # Returns an Integer number of failures.
273
322
  def run_worker(iterator)
274
323
  iterator.each do |item|
275
324
  puts " #{item.inspect}"
@@ -286,27 +335,34 @@ module TestQueue
286
335
  worker.failure_output = ''
287
336
  end
288
337
 
289
- def reap_worker(blocking=true)
290
- if pid = Process.waitpid(-1, blocking ? 0 : Process::WNOHANG) and worker = @workers.delete(pid)
338
+ def reap_workers(blocking=true)
339
+ @workers.delete_if do |_, worker|
340
+ if Process.waitpid(worker.pid, blocking ? 0 : Process::WNOHANG).nil?
341
+ next false
342
+ end
343
+
291
344
  worker.status = $?
292
345
  worker.end_time = Time.now
293
346
 
294
- if File.exists?(file = "/tmp/test_queue_worker_#{pid}_output")
347
+ if File.exists?(file = "/tmp/test_queue_worker_#{worker.pid}_output")
295
348
  worker.output = IO.binread(file)
296
349
  FileUtils.rm(file)
297
350
  end
298
351
 
299
- if File.exists?(file = "/tmp/test_queue_worker_#{pid}_stats")
300
- worker.stats = Marshal.load(IO.binread(file))
352
+ if File.exists?(file = "/tmp/test_queue_worker_#{worker.pid}_suites")
353
+ worker.suites.replace(Marshal.load(IO.binread(file)))
301
354
  FileUtils.rm(file)
302
355
  end
303
356
 
304
357
  relay_to_master(worker) if relay?
305
358
  worker_completed(worker)
359
+
360
+ true
306
361
  end
307
362
  end
308
363
 
309
364
  def worker_completed(worker)
365
+ return if @aborting
310
366
  @completed << worker
311
367
  puts worker.output if ENV['TEST_QUEUE_VERBOSE'] || worker.status.exitstatus != 0
312
368
  end
@@ -315,9 +371,17 @@ module TestQueue
315
371
  return if relay?
316
372
  remote_workers = 0
317
373
 
318
- until @queue.empty? && remote_workers == 0
374
+ until @discovering_suites_pid.nil? && @queue.empty? && remote_workers == 0
375
+ queue_status(@start_time, @queue.size, @workers.size, remote_workers)
376
+
377
+ # Make sure our discovery process is still doing OK.
378
+ if @discovering_suites_pid && Process.waitpid(@discovering_suites_pid, Process::WNOHANG) != nil
379
+ @discovering_suites_pid = nil
380
+ abort("Discovering suites failed.") unless $?.success?
381
+ end
382
+
319
383
  if IO.select([@server], nil, nil, 0.1).nil?
320
- reap_worker(false) if @workers.any? # check for worker deaths
384
+ reap_workers(false) # check for worker deaths
321
385
  else
322
386
  sock = @server.accept
323
387
  cmd = sock.gets.strip
@@ -325,8 +389,10 @@ module TestQueue
325
389
  when /^POP/
326
390
  # If we have a slave from a different test run, don't respond, and it will consider the test run done.
327
391
  if obj = @queue.shift
328
- data = Marshal.dump(obj.to_s)
392
+ data = Marshal.dump(obj)
329
393
  sock.write(data)
394
+ elsif @discovering_suites_pid
395
+ sock.write(Marshal.dump("WAIT"))
330
396
  end
331
397
  when /^SLAVE (\d+) ([\w\.-]+) (\w+)(?: (.+))?/
332
398
  num = $1.to_i
@@ -349,16 +415,20 @@ module TestQueue
349
415
  worker = Marshal.load(data)
350
416
  worker_completed(worker)
351
417
  remote_workers -= 1
418
+ when /^NEW SUITE (.+)/
419
+ suite_name, path = Marshal.load($1)
420
+ enqueue_discovered_suite(suite_name, path)
421
+ when /^KABOOM/
422
+ # worker reporting an abnormal number of test failures;
423
+ # stop everything immediately and report the results.
424
+ break
352
425
  end
353
426
  sock.close
354
427
  end
355
428
  end
356
429
  ensure
357
430
  stop_master
358
-
359
- until @workers.empty?
360
- reap_worker
361
- end
431
+ reap_workers
362
432
  end
363
433
 
364
434
  def relay?
@@ -391,5 +461,44 @@ module TestQueue
391
461
  ensure
392
462
  sock.close if sock
393
463
  end
464
+
465
+ def kill_workers
466
+ @workers.each do |pid, worker|
467
+ Process.kill 'KILL', pid
468
+ end
469
+
470
+ reap_workers
471
+ end
472
+
473
+ # Stop the test run immediately.
474
+ #
475
+ # message - String message to print to the console when exiting.
476
+ #
477
+ # Doesn't return.
478
+ def abort(message)
479
+ @aborting = true
480
+ kill_workers
481
+ Kernel::abort("Aborting: #{message}")
482
+ end
483
+
484
+ # Subclasses can override to monitor the status of the queue.
485
+ #
486
+ # For example, you may want to record metrics about how quickly remote
487
+ # workers connect, or abort the build if not enough connect.
488
+ #
489
+ # This method is called very frequently during the test run, so don't do
490
+ # anything expensive/blocking.
491
+ #
492
+ # This method is not called on remote masters when using remote workers,
493
+ # only on the central master.
494
+ #
495
+ # start_time - Time when the test run began
496
+ # queue_size - Integer number of suites left in the queue
497
+ # local_worker_count - Integer number of active local workers
498
+ # remote_worker_count - Integer number of active remote workers
499
+ #
500
+ # Returns nothing.
501
+ def queue_status(start_time, queue_size, local_worker_count, remote_worker_count)
502
+ end
394
503
  end
395
504
  end
@@ -14,32 +14,101 @@ module Cucumber
14
14
  end
15
15
  end
16
16
  end
17
+
18
+ class Runtime
19
+ if defined?(::Cucumber::Runtime::FeaturesLoader)
20
+ # Without this module, Runtime#features would load all features specified
21
+ # on the command line. We want to avoid that and load only the features
22
+ # each worker needs ourselves, so we override the default behavior to let
23
+ # us put our iterator in place without loading any features directly.
24
+ module InjectableFeatures
25
+ def features
26
+ return @features if defined?(@features)
27
+ super
28
+ end
29
+
30
+ def features=(iterator)
31
+ @features = ::Cucumber::Ast::Features.new
32
+ @features.features = iterator
33
+ end
34
+ end
35
+
36
+ prepend InjectableFeatures
37
+ else
38
+ attr_writer :features
39
+ end
40
+ end
17
41
  end
18
42
 
19
43
  module TestQueue
20
44
  class Runner
21
45
  class Cucumber < Runner
22
46
  def initialize
23
- @cli = ::Cucumber::Cli::Main.new(ARGV.dup)
24
- @runtime = ::Cucumber::Runtime.new(@cli.configuration)
25
- @features_loader = @runtime.send(:features)
26
- features = @features_loader.features.sort_by{ |s| -(stats[s.to_s] || 0) }
27
- super(features)
47
+ super(TestFramework::Cucumber.new)
28
48
  end
29
49
 
30
50
  def run_worker(iterator)
31
- @features_loader.features = iterator
32
- @cli.execute!(@runtime)
51
+ runtime = @test_framework.runtime
52
+ runtime.features = iterator
53
+
54
+ @test_framework.cli.execute!(runtime)
55
+
56
+ if runtime.respond_to?(:summary_report, true)
57
+ runtime.send(:summary_report).test_cases.total_failed
58
+ else
59
+ runtime.results.scenarios(:failed).size
60
+ end
33
61
  end
34
62
 
35
63
  def summarize_worker(worker)
36
- worker.stats.each do |s, val|
37
- stats[s.to_s] = val
64
+ output = worker.output.gsub(/\e\[\d+./, '')
65
+ worker.summary = output.split("\n").grep(/^\d+ (scenarios?|steps?)/).first
66
+ worker.failure_output = output.scan(/^Failing Scenarios:\n(.*)\n\d+ scenarios?/m).join("\n")
67
+ end
68
+ end
69
+ end
70
+
71
+ class TestFramework
72
+ class Cucumber < TestFramework
73
+ class FakeKernel
74
+ def exit(n)
75
+ if $!
76
+ # Let Cucumber exit for raised exceptions.
77
+ Kernel.exit(n)
78
+ end
79
+ # Don't let Cucumber exit to indicate test failures. We want to
80
+ # return the number of failures from #run_worker instead.
38
81
  end
82
+ end
39
83
 
40
- output = worker.output.gsub(/\e\[\d+./,'')
41
- worker.summary = output.split("\n").grep(/^\d+ (scenarios?|steps?)/).first
42
- worker.failure_output = output.scan(/^Failing Scenarios:\n(.*)\n\d+ scenarios?/m).join("\n")
84
+ def cli
85
+ @cli ||= ::Cucumber::Cli::Main.new(ARGV.dup, $stdin, $stdout, $stderr, FakeKernel.new)
86
+ end
87
+
88
+ def runtime
89
+ @runtime ||= ::Cucumber::Runtime.new(cli.configuration)
90
+ end
91
+
92
+ def all_suite_files
93
+ if runtime.respond_to?(:feature_files, true)
94
+ runtime.send(:feature_files)
95
+ else
96
+ cli.configuration.feature_files
97
+ end
98
+ end
99
+
100
+ def suites_from_file(path)
101
+ if defined?(::Cucumber::Core::Gherkin::Document)
102
+ source = ::Cucumber::Runtime::NormalisedEncodingFile.read(path)
103
+ doc = ::Cucumber::Core::Gherkin::Document.new(path, source)
104
+ [[File.basename(doc.uri), doc]]
105
+ else
106
+ loader =
107
+ ::Cucumber::Runtime::FeaturesLoader.new([path],
108
+ cli.configuration.filters,
109
+ cli.configuration.tag_expression)
110
+ loader.features.map { |feature| [feature.title, feature] }
111
+ end
43
112
  end
44
113
  end
45
114
  end