binpacker 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d0c30480b18ff759a0611116ea6c21fd5e5034525aa4ebfa95ab65fb3f2fdf2
4
- data.tar.gz: 9b273d060dfbef0b2812bc2617e7eeaed00bee6247c6381d51d8b075132b28e8
3
+ metadata.gz: dfee21fa79b8b157afbe89bba482a2a8c2b95c4ec22dbff36117a269275f47ef
4
+ data.tar.gz: 636266dc852e315dffb3e1b815f39b615e4a73be4cfa9dac445a01ef7b7d76fc
5
5
  SHA512:
6
- metadata.gz: d765fe4fc0741dbd13445707da9f937e4571090697c6767ddfa9ec3391e0a9a9a76935fab3ba820eb410e5bcb1d69c617668e514dd4656c46134b1a15cc87097
7
- data.tar.gz: 03cd6766727a30e69e259c356b53461ac54bbed7d454904535a91e9e448c4b3fe6750104e63bf7bd1ceed5eb111f47f11e88231397d58ae52304339dcdbbfca9
6
+ metadata.gz: 3eb4035f35100358451c5efa9ba5ae178932defb326a988d9d1e6da1521c79b201e68ba075f8bd2575f36a4d0ce33d92b40fd5117afbac21068e3eaabb93daf2
7
+ data.tar.gz: 788f484e43e7d9c6d77519cabe924f6080e77ebff5e4fb1125e38721c165c1bf6bbdd6e0f4a9fa1140a3b1d62da770be746cbcd4f68dce15f36d0ab556d949c9
data/exe/binpacker-worker CHANGED
@@ -3,19 +3,34 @@
3
3
 
4
4
  require "json"
5
5
  require "optparse"
6
+ require "set"
6
7
  require "tempfile"
7
8
 
9
+ begin
10
+ require "minitest"
11
+ rescue LoadError
12
+ # Minitest not installed — rspec-only use
13
+ end
14
+
8
15
  def run_rspec(files, wid, rspec_args)
9
16
  outfile = Tempfile.new("binpacker-rspec-out")
17
+ progress_outfile = Tempfile.new("binpacker-rspec-progress")
10
18
  outfile.close
19
+ progress_outfile.close
20
+
11
21
  cmd = [
12
22
  "rspec",
13
23
  "--format", "json", "--out", outfile.path,
14
- "--format", "progress", "--out", "/dev/stderr",
24
+ "--format", "progress", "--out", progress_outfile.path,
15
25
  *rspec_args,
16
26
  *files
17
27
  ]
18
- success = system(*cmd)
28
+ system(*cmd, exception: false)
29
+ exit_status = $?.exitstatus || 1
30
+
31
+ if File.exist?(progress_outfile.path) && File.size(progress_outfile.path) > 0
32
+ $stderr.write File.read(progress_outfile.path, encoding: "UTF-8")
33
+ end
19
34
 
20
35
  data = (File.exist?(outfile.path) && File.size(outfile.path) > 0) ? JSON.parse(File.read(outfile.path, encoding: "UTF-8")) : {}
21
36
  examples = data["examples"] || []
@@ -25,65 +40,170 @@ def run_rspec(files, wid, rspec_args)
25
40
  { file: ex["file_path"], name: ex["full_description"] || ex["description"], time: ex["run_time"] || 0.0 }
26
41
  end
27
42
 
28
- outfile.unlink
29
- [$?.exitstatus || 1, results, examples.size, (summary["failure_count"] || 0) == 0 ? examples.size : examples.size - summary["failure_count"]]
43
+ [exit_status, results, examples.size, (summary["failure_count"] || 0) == 0 ? examples.size : examples.size - summary["failure_count"]]
44
+ ensure
45
+ outfile&.unlink
46
+ progress_outfile&.unlink
47
+ end
48
+
49
+ def add_project_load_paths
50
+ %w[lib test].each do |path|
51
+ expanded = File.expand_path(path)
52
+ $LOAD_PATH.unshift(expanded) if Dir.exist?(expanded) && !$LOAD_PATH.include?(expanded)
53
+ end
54
+ end
55
+
56
+ def run_minitest_after_run_hooks
57
+ return unless Minitest.class_variable_defined?(:@@after_run)
58
+
59
+ Minitest.class_variable_get(:@@after_run).reverse_each(&:call)
60
+ end
61
+
62
+ def run_minitest(tests, wid, minitest_args)
63
+ unless defined?(Minitest)
64
+ $stderr.puts "[worker-#{wid}] minitest is not available"
65
+ return [1, [], 0, 0]
66
+ end
67
+
68
+ def Minitest.autorun; end
69
+ add_project_load_paths
70
+
71
+ options = Minitest.process_args(minitest_args.dup)
72
+ options[:io] = $stderr
73
+ Minitest.seed = options[:seed]
74
+ srand Minitest.seed
75
+
76
+ selected = []
77
+ tests.group_by { |test| test["file"] }.each do |file, test_entries|
78
+ path = File.expand_path(file)
79
+ requested_names = test_entries.map { |test| test["name"] }.to_set
80
+ klasses_before = Minitest::Runnable.runnables.dup
81
+ $stderr.puts "[worker-#{wid}] loading #{file}"
82
+ load path
83
+
84
+ (Minitest::Runnable.runnables - klasses_before).each do |klass|
85
+ klass.filter_runnable_methods(options).each do |method_name|
86
+ full_name = "#{klass}##{method_name}"
87
+ selected << [klass, method_name, file, full_name] if requested_names.include?(full_name)
88
+ end
89
+ end
90
+ rescue StandardError => e
91
+ $stderr.puts "[worker-#{wid}] minitest load failed for #{file}: #{e.class}: #{e.message}"
92
+ return [1, [], 0, 0]
93
+ end
94
+
95
+ return [0, [], 0, 0] if selected.empty?
96
+
97
+ timing_reporter = TimingReporter.new
98
+ reporter = Minitest::CompositeReporter.new
99
+ reporter << Minitest::SummaryReporter.new($stderr, options)
100
+ reporter << Minitest::ProgressReporter.new($stderr, options) unless options[:quiet]
101
+ reporter << timing_reporter
102
+
103
+ Minitest.reporter = reporter
104
+ Minitest.init_plugins(options)
105
+ Minitest.reporter = nil
106
+
107
+ selected_files = selected.to_h { |_klass, _method_name, file, full_name| [full_name, file] }
108
+
109
+ Minitest.parallel_executor.start if Minitest.parallel_executor.respond_to?(:start)
110
+ reporter.start
111
+ hook_error = nil
112
+ begin
113
+ selected.each do |klass, method_name, _file, _full_name|
114
+ klass.run(klass, method_name, reporter)
115
+ end
116
+ ensure
117
+ Minitest.parallel_executor.shutdown if Minitest.parallel_executor.respond_to?(:shutdown)
118
+ reporter.report
119
+ begin
120
+ run_minitest_after_run_hooks
121
+ rescue StandardError => e
122
+ hook_error = e
123
+ $stderr.puts "[worker-#{wid}] minitest after_run failed: #{e.class}: #{e.message}"
124
+ end
125
+ end
126
+
127
+ failures = timing_reporter.results.count { |result| !result.passed? }
128
+ results = timing_reporter.results.map do |result|
129
+ full_name = "#{result.klass}##{result.name}"
130
+ {
131
+ file: selected_files.fetch(full_name, result.source_location.first),
132
+ name: full_name,
133
+ time: result.time
134
+ }
135
+ end
136
+
137
+ exit_code = reporter.passed? && hook_error.nil? ? 0 : 1
138
+ passed = results.size - failures
139
+ [exit_code, results, results.size, passed]
30
140
  end
31
141
 
32
- def run_minitest(files, wid, _args)
33
- results = []
34
- exit_code = 0
35
- files.each do |file|
36
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
- s = system("ruby", "-Ilib:test", file, exception: false)
38
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
39
- results << { file: file, name: file, time: elapsed }
40
- exit_code = s.exitstatus unless s.success?
142
+ if defined?(Minitest)
143
+ class TimingReporter < Minitest::AbstractReporter
144
+ attr_reader :results
145
+
146
+ def initialize
147
+ super()
148
+ @results = []
149
+ end
150
+
151
+ def record(result)
152
+ @results << result
153
+ end
41
154
  end
42
- [exit_code, results, results.size, exit_code == 0 ? results.size : 0]
43
155
  end
44
156
 
45
157
  options = { runner: "rspec", id: 0 }
46
158
  rspec_args = []
159
+ minitest_args = []
47
160
  OptionParser.new do |opts|
48
161
  opts.on("--runner RUNNER", "rspec or minitest") { |v| options[:runner] = v }
49
162
  opts.on("--id ID", "Worker ID") { |v| options[:id] = v.to_i }
50
163
  opts.on("--rspec-arg ARG", "Pass-through to rspec") { |v| rspec_args << v }
164
+ opts.on("--minitest-arg ARG", "Pass-through to minitest") { |v| minitest_args << v }
51
165
  end.parse!
52
166
 
53
167
  wid = options[:id]
168
+ exit_code = 0
54
169
  $stderr.puts "[worker-#{wid}] #{options[:runner]} worker started"
55
- $stdout.puts JSON.generate({ type: "ready" })
56
- $stdout.flush
57
-
58
- tests = []
59
- $stdin.each_line do |line|
60
- data = JSON.parse(line.strip)
61
- break if data["type"] == "done"
62
- tests << data
63
- rescue JSON::ParserError
64
- $stderr.puts "[worker-#{wid}] skip: #{line.strip[0..60]}"
65
- end
66
170
 
67
- if tests.empty?
68
- $stdout.puts JSON.generate({ type: "result", exit_code: 0, passed: true, total: 0, passed_count: 0 })
171
+ loop do
172
+ $stdout.puts JSON.generate({ type: "ready" })
69
173
  $stdout.flush
70
- exit 0
71
- end
72
174
 
73
- files = tests.map { |t| t["file"] }.uniq
74
- $stderr.puts "[worker-#{wid}] #{files.size} files, #{tests.size} test entries"
175
+ tests = []
176
+ done_or_eof = false
177
+ $stdin.each_line do |line|
178
+ data = JSON.parse(line.strip)
179
+ if data["type"] == "done"
180
+ done_or_eof = true
181
+ break
182
+ end
183
+ tests << data
184
+ rescue JSON::ParserError
185
+ $stderr.puts "[worker-#{wid}] skip: #{line.strip[0..60]}"
186
+ end
75
187
 
76
- exit_code, results, total, passed = case options[:runner]
77
- when "rspec" then run_rspec(files, wid, rspec_args)
78
- when "minitest" then run_minitest(files, wid, [])
79
- else
80
- $stderr.puts "[worker-#{wid}] unknown runner: #{options[:runner]}"
81
- [1, [], 0, 0]
82
- end
188
+ break if done_or_eof && tests.empty?
189
+ break if tests.empty? && ($stdin.eof? || $stdin.closed?)
190
+
191
+ files = tests.map { |t| t["file"] }.uniq
192
+ $stderr.puts "[worker-#{wid}] batch: #{files.size} files, #{tests.size} entries"
83
193
 
84
- results.each do |r|
85
- $stdout.puts JSON.generate({ type: "timing", file: r[:file], name: r[:name], time: r[:time] })
194
+ exit_code, results, total, passed = case options[:runner]
195
+ when "rspec" then run_rspec(files, wid, rspec_args)
196
+ when "minitest" then run_minitest(tests, wid, minitest_args)
197
+ else
198
+ $stderr.puts "[worker-#{wid}] unknown runner: #{options[:runner]}"
199
+ [1, [], 0, 0]
200
+ end
201
+
202
+ results.each do |r|
203
+ $stdout.puts JSON.generate({ type: "timing", file: r[:file], name: r[:name], time: r[:time] })
204
+ end
205
+ $stdout.puts JSON.generate({ type: "batch_result", exit_code: exit_code, passed: exit_code == 0, total: total, passed_count: passed })
206
+ $stdout.flush
86
207
  end
87
- $stdout.puts JSON.generate({ type: "result", exit_code: exit_code, passed: exit_code == 0, total: total, passed_count: passed })
88
- $stdout.flush
89
- exit exit_code
208
+
209
+ exit exit_code || 0
@@ -41,7 +41,7 @@ module Binpacker
41
41
  "--format", "json", "--out", outfile.path
42
42
  ]
43
43
  when "minitest"
44
- cmd = ["ruby", "-Ilib:test", test.file, "--name", test.name]
44
+ cmd = ["ruby", "-Ilib:test", test.file, "--name", "/^#{Regexp.escape(test.name)}$/"]
45
45
  else
46
46
  raise ConfigError, "unsupported runner for calibration: #{@config.test_runner}"
47
47
  end
data/lib/binpacker/cli.rb CHANGED
@@ -14,6 +14,7 @@ module Binpacker
14
14
  @command = nil
15
15
  @profile = nil
16
16
  @passthrough = []
17
+ @quiet = false
17
18
  parse!
18
19
  end
19
20
 
@@ -23,6 +24,8 @@ module Binpacker
23
24
  cmd_calibrate
24
25
  when "run"
25
26
  cmd_run
27
+ when "init"
28
+ cmd_init
26
29
  when "--version", "-v"
27
30
  puts "binpacker #{Binpacker::VERSION}"
28
31
  when "--help", "-h", nil
@@ -51,6 +54,10 @@ module Binpacker
51
54
  opts.on("--help", "Show help") do
52
55
  @command ||= "--help"
53
56
  end
57
+
58
+ opts.on("--quiet", "Suppress worker output") do
59
+ @quiet = true
60
+ end
54
61
  end
55
62
 
56
63
  # Extract passthrough arguments after "--"
@@ -63,6 +70,41 @@ module Binpacker
63
70
  @command = remaining.shift
64
71
  end
65
72
 
73
+ def cmd_init
74
+ config_path = Pathname.pwd.join("binpacker.yml")
75
+ if config_path.exist?
76
+ puts "binpacker.yml already exists at #{config_path}"
77
+ exit 1
78
+ end
79
+
80
+ framework = detect_framework
81
+ pattern = framework == "minitest" ? "test/**/*_test.rb" : "spec/**/*_spec.rb"
82
+ runner = framework
83
+
84
+ yaml = <<~YAML
85
+ profiles:
86
+ default:
87
+ test_runner: #{runner}
88
+ workers: auto
89
+ timing_file: binpacker.timings
90
+ test_pattern: "#{pattern}"
91
+ scheduler:
92
+ algorithm: lpt
93
+ steal_enabled: true
94
+ ci:
95
+ extends: default
96
+ workers: 4
97
+ YAML
98
+
99
+ config_path.write(yaml)
100
+ puts "Created #{config_path}"
101
+ puts "Detected test framework: #{framework}"
102
+ puts ""
103
+ puts "Next steps:"
104
+ puts " 1. binpacker calibrate (seed timing data)"
105
+ puts " 2. binpacker run (run in parallel)"
106
+ end
107
+
66
108
  def cmd_calibrate
67
109
  config = Config.new(profile: @profile)
68
110
  discovery_klass = config.test_runner == "rspec" ? RSpecDiscovery : MinitestDiscovery
@@ -79,21 +121,33 @@ module Binpacker
79
121
 
80
122
  def cmd_run
81
123
  config = Config.new(profile: @profile)
82
- orchestrator = Orchestrator.new(config, passthrough: @passthrough)
124
+ orchestrator = Orchestrator.new(config, passthrough: @passthrough, quiet: @quiet)
83
125
 
84
126
  puts "binpacker starting (#{config.worker_count} workers, profile: #{config.profile})"
85
127
  result = orchestrator.run
128
+ unit = test_unit_label(config)
86
129
 
87
130
  if result[:passed]
88
- puts "All #{result[:total]} examples passed across #{config.worker_count} workers."
131
+ puts "All #{result[:total]} #{pluralize(result[:total], unit)} passed across #{config.worker_count} workers."
89
132
  exit 0
133
+ elsif result[:empty_filter]
134
+ puts "No tests matched the Minitest filter."
135
+ exit 1
90
136
  else
91
137
  failed = result[:total] - result[:passed_count]
92
- puts "#{failed}/#{result[:total]} examples failed."
138
+ puts "#{failed}/#{result[:total]} #{pluralize(failed, unit)} failed."
93
139
  exit 1
94
140
  end
95
141
  end
96
142
 
143
+ def test_unit_label(config)
144
+ config.test_runner == "rspec" ? "example" : "test"
145
+ end
146
+
147
+ def pluralize(count, word)
148
+ count == 1 ? word : "#{word}s"
149
+ end
150
+
97
151
  def print_help
98
152
  puts <<~HELP
99
153
  binpacker #{Binpacker::VERSION}
@@ -101,16 +155,24 @@ module Binpacker
101
155
  Commands:
102
156
  run Execute tests across worker processes
103
157
  calibrate Run tests serially to generate timing data
158
+ init Create binpacker.yml with auto-detected settings
104
159
 
105
160
  Options:
106
161
  --profile NAME Select profile from binpacker.yml
107
162
  --help Show this message
108
163
 
109
164
  Examples:
165
+ binpacker init
110
166
  binpacker run --profile ci
111
167
  binpacker run -- --tag ~slow
112
168
  binpacker calibrate
113
169
  HELP
114
170
  end
171
+
172
+ def detect_framework
173
+ return "minitest" if Dir.glob("test*/**/*_test.rb").any?
174
+ return "minitest" if Dir.glob("test*/**/test_*.rb").any?
175
+ "rspec"
176
+ end
115
177
  end
116
178
  end
@@ -10,6 +10,7 @@ module Binpacker
10
10
  DEFAULTS = {
11
11
  "test_runner" => "rspec",
12
12
  "workers" => "auto",
13
+ "test_granularity" => "file",
13
14
  "timing_file" => "binpacker.timings",
14
15
  "test_pattern" => "spec/**/*_spec.rb",
15
16
  "test_exclude" => [],
@@ -77,6 +78,7 @@ module Binpacker
77
78
 
78
79
  def build_profile(name)
79
80
  profiles = @raw.fetch("profiles", {})
81
+ return DEFAULTS.dup if profiles.empty? && name == "default"
80
82
  entry = profiles[name]
81
83
  raise ConfigError, "profile '#{name}' not found in binpacker.yml" unless entry
82
84
  parent = entry["extends"]
@@ -2,14 +2,16 @@
2
2
 
3
3
  module Binpacker
4
4
  class Orchestrator
5
- def initialize(config, passthrough: [])
5
+ BATCH_SIZE = 10
6
+
7
+ def initialize(config, passthrough: [], quiet: false)
6
8
  @config = config
7
9
  @passthrough = passthrough
10
+ @quiet = quiet
8
11
  end
9
12
 
10
13
  def run
11
14
  tests = discover
12
-
13
15
  timing = Timing.new(@config.timing_file)
14
16
  timings = timing.load_with_fallback(tests)
15
17
 
@@ -22,19 +24,48 @@ module Binpacker
22
24
 
23
25
  runner_class = TestRunner.for(@config.test_runner)
24
26
  workers = queues.map.with_index do |queue, idx|
25
- Worker.new(idx, runner_class, passthrough: @passthrough).tap(&:start)
27
+ Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
26
28
  end
27
29
 
30
+ if @config.scheduler["steal_enabled"]
31
+ run_dynamic(workers, queues, timing, tests)
32
+ else
33
+ run_static(workers, queues, timing, tests)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def discover
40
+ case @config.test_runner
41
+ when "rspec"
42
+ RSpecDiscovery.new(@config).enumerate
43
+ when "minitest"
44
+ MinitestDiscovery.new(@config).enumerate
45
+ else
46
+ raise ConfigError, "unsupported runner: #{@config.test_runner}"
47
+ end
48
+ end
49
+
50
+ def run_static(workers, queues, timing, tests)
51
+ queue_totals = queues.map(&:size)
52
+ progress = ProgressDisplay.new(workers.size)
53
+
28
54
  workers.zip(queues).each do |worker, queue|
29
55
  worker.send_tests(queue.remaining)
56
+ progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || "")
30
57
  end
58
+ progress.refresh
59
+
60
+ workers.each(&:signal_done)
31
61
 
32
62
  all_timings = []
33
63
  all_passed = true
34
64
  total_examples = 0
35
65
  passed_examples = 0
36
-
37
- workers.each(&:signal_done)
66
+ worker_time = Array.new(workers.size, 0.0)
67
+ worker_examples = Array.new(workers.size, 0)
68
+ worker_passed = Array.new(workers.size, 0)
38
69
 
39
70
  workers.each do |worker|
40
71
  worker.collect_results
@@ -42,6 +73,11 @@ module Binpacker
42
73
  all_passed &&= worker.success?
43
74
  total_examples += worker.example_count
44
75
  passed_examples += worker.passed_count
76
+ worker_examples[worker.id] = worker.example_count
77
+ worker_passed[worker.id] = worker.passed_count
78
+ worker_time[worker.id] = worker.timings.sum { |t| t[:time] }
79
+ progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: "done")
80
+ progress.refresh
45
81
  rescue WorkerError => e
46
82
  $stderr.puts "worker #{worker.id} error: #{e.message}"
47
83
  all_passed = false
@@ -49,26 +85,163 @@ module Binpacker
49
85
  worker.cleanup
50
86
  end
51
87
 
88
+ progress.finish
89
+
90
+ worker_stats = workers.map.with_index do |w, i|
91
+ {
92
+ files: queue_totals[i],
93
+ total_time: worker_time[i],
94
+ examples: worker_examples[i],
95
+ passed: worker_passed[i]
96
+ }
97
+ end
98
+ progress.summary(worker_stats)
99
+
100
+ finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
101
+ end
102
+
103
+ def run_dynamic(workers, queues, timing, tests)
104
+ all_timings = []
105
+ all_passed = true
106
+ total_examples = 0
107
+ passed_examples = 0
108
+ active = []
109
+
110
+ queue_totals = queues.map(&:size)
111
+ worker_done = Array.new(workers.size, 0)
112
+ batch_sizes = Array.new(workers.size, 0)
113
+ worker_time = Array.new(workers.size, 0.0)
114
+ worker_examples = Array.new(workers.size, 0)
115
+ worker_passed = Array.new(workers.size, 0)
116
+
117
+ progress = ProgressDisplay.new(workers.size)
118
+
119
+ workers.zip(queues).each do |worker, queue|
120
+ batch = drain_batch(queue)
121
+ if batch.empty?
122
+ worker.signal_done
123
+ worker.collect_results
124
+ all_timings.concat(worker.timings)
125
+ all_passed &&= worker.success?
126
+ total_examples += worker.example_count
127
+ passed_examples += worker.passed_count
128
+ worker_examples[worker.id] = worker.example_count
129
+ worker_passed[worker.id] = worker.passed_count
130
+ worker.cleanup
131
+ worker_done[worker.id] = queue_totals[worker.id]
132
+ progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: "done")
133
+ else
134
+ worker.send_tests(batch)
135
+ worker.batch_done
136
+ active << worker
137
+ batch_sizes[worker.id] = batch.size
138
+ current_file = batch.first&.file || ""
139
+ progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
140
+ end
141
+ end
142
+
143
+ until active.empty?
144
+ ready = active.find { |w| w.wait_for_batch }
145
+ unless ready
146
+ active.reject! { |w| w.status == :crashed || w.status == :error }
147
+ sleep 0.1
148
+ next
149
+ end
150
+
151
+ begin
152
+ all_passed &&= ready.success?
153
+ total_examples += ready.example_count
154
+ passed_examples += ready.passed_count
155
+
156
+ worker_done[ready.id] += batch_sizes[ready.id]
157
+ worker_examples[ready.id] = ready.example_count
158
+ worker_passed[ready.id] = ready.passed_count
159
+
160
+ own_queue = queues[ready.id]
161
+ next_batch = drain_batch(own_queue)
162
+
163
+ if next_batch.empty?
164
+ donor = queues.reject(&:empty?).max_by(&:size)
165
+ next_batch = drain_batch(donor) if donor
166
+ end
167
+
168
+ if next_batch.any?
169
+ ready.send_tests(next_batch)
170
+ ready.batch_done
171
+ batch_sizes[ready.id] = next_batch.size
172
+ current_file = next_batch.first&.file || ""
173
+ progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
174
+ progress.refresh
175
+ else
176
+ ready.signal_done
177
+ all_timings.concat(ready.timings)
178
+ active.delete(ready)
179
+ worker_done[ready.id] = queue_totals[ready.id]
180
+ progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: "done")
181
+ progress.refresh
182
+ end
183
+ rescue WorkerError => e
184
+ $stderr.puts "worker #{ready.id} error: #{e.message}"
185
+ all_passed = false
186
+ active.delete(ready)
187
+ end
188
+ end
189
+
190
+ progress.finish
191
+
192
+ worker_stats = workers.map.with_index do |w, i|
193
+ tw = w.timings.sum { |t| t[:time] }
194
+ {
195
+ files: queue_totals[i],
196
+ total_time: tw > 0 ? tw : worker_time[i],
197
+ examples: worker_examples[i],
198
+ passed: worker_passed[i]
199
+ }
200
+ end
201
+ progress.summary(worker_stats)
202
+
203
+ workers.each(&:cleanup)
204
+ finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
205
+ end
206
+
207
+ def drain_batch(queue)
208
+ return [] if queue.nil? || queue.empty?
209
+ batch = []
210
+ BATCH_SIZE.times do
211
+ test = queue.pop
212
+ break unless test
213
+ batch << test
214
+ end
215
+ batch
216
+ end
217
+
218
+ def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
52
219
  timing.append_all(all_timings) unless all_timings.empty?
220
+ empty_filter = minitest_empty_filter?(tests, total_examples)
221
+ all_passed = false if empty_filter
53
222
 
54
223
  {
55
224
  passed: all_passed,
56
225
  total: total_examples,
57
226
  passed_count: passed_examples,
58
- timings: all_timings
227
+ timings: all_timings,
228
+ empty_filter: empty_filter
59
229
  }
60
230
  end
61
231
 
62
- private
232
+ def minitest_empty_filter?(tests, total_examples)
233
+ return false unless @config.test_runner == "minitest"
234
+ return false unless tests.any?
235
+ return false unless total_examples.zero?
63
236
 
64
- def discover
65
- case @config.test_runner
66
- when "rspec"
67
- RSpecDiscovery.new(@config).enumerate
68
- when "minitest"
69
- MinitestDiscovery.new(@config).enumerate
70
- else
71
- raise ConfigError, "unsupported runner: #{@config.test_runner}"
237
+ minitest_include_filter?
238
+ end
239
+
240
+ def minitest_include_filter?
241
+ @passthrough.any? do |arg|
242
+ %w[--name --include -n -i].include?(arg) ||
243
+ arg.start_with?("--name=", "--include=") ||
244
+ (arg.start_with?("-n", "-i") && arg.length > 2)
72
245
  end
73
246
  end
74
247
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binpacker
4
+ class ProgressDisplay
5
+ CI_INTERVAL = 15 # seconds between CI output lines
6
+
7
+ def initialize(worker_count, tty: $stdout.tty?)
8
+ @worker_count = worker_count
9
+ @tty = tty
10
+ @workers = Array.new(worker_count) { { done: 0, total: 0, file: "", elapsed: 0.0 } }
11
+ @start = Time.now
12
+ @last_ci_output = Time.at(Time.now.to_f - CI_INTERVAL)
13
+ @lines_written = 0
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def update(worker_id, done:, total:, file:, elapsed: 0.0)
18
+ @mutex.synchronize do
19
+ w = @workers[worker_id]
20
+ w[:done] = done
21
+ w[:total] = total
22
+ w[:file] = file
23
+ w[:elapsed] = elapsed
24
+ end
25
+ end
26
+
27
+ def refresh
28
+ if @tty
29
+ redraw
30
+ else
31
+ periodic_output
32
+ end
33
+ end
34
+
35
+ def finish(worker_stats = [])
36
+ return unless @tty
37
+ redraw
38
+ $stdout.puts
39
+ end
40
+
41
+ def summary(worker_stats)
42
+ active = worker_stats.reject { |s| s[:files] == 0 && s[:examples] == 0 }
43
+ return if active.empty?
44
+
45
+ active.each_with_index do |s, i|
46
+ t = format_time(s[:total_time])
47
+ wid = worker_stats.index(s)
48
+ $stdout.puts " Worker #{wid}: #{s[:files]} files, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
49
+ end
50
+ total_files = active.sum { |s| s[:files] }
51
+ total_time = active.sum { |s| s[:total_time] }
52
+ total_examples = active.sum { |s| s[:examples] }
53
+ times = active.map { |s| s[:total_time] }
54
+ mean = total_time / active.size
55
+ max_dev = times.map { |t| (t - mean).abs }.max
56
+ dev_pct = mean > 0 ? (max_dev / mean * 100).round(1) : 0
57
+
58
+ $stdout.puts " ──"
59
+ $stdout.puts " Total: #{total_files} files, #{format_time(total_time)} | #{total_examples} examples"
60
+ $stdout.puts " Balance: max deviation #{format_time(max_dev)} (#{dev_pct}%)"
61
+ end
62
+
63
+ private
64
+
65
+ def redraw
66
+ @mutex.synchronize do
67
+ clear_lines
68
+ @workers.each_with_index do |w, i|
69
+ bar = build_bar(w[:done], w[:total])
70
+ status = w[:total] > 0 && w[:done] >= w[:total] ? "done" : w[:file][-50..] || ""
71
+ $stdout.puts format_line(i, bar, w[:done], w[:total], status, w[:elapsed])
72
+ end
73
+ @lines_written = @worker_count
74
+ end
75
+ end
76
+
77
+ def clear_lines
78
+ return if @lines_written == 0
79
+ @lines_written.times do
80
+ $stdout.print "\033[A\033[K"
81
+ end
82
+ end
83
+
84
+ def build_bar(done, total)
85
+ return "[----------]" if total == 0
86
+ width = 10
87
+ filled = (done.to_f / total * width).round
88
+ "[#{'█' * filled}#{'░' * (width - filled)}]"
89
+ end
90
+
91
+ def format_line(idx, bar, done, total, file, elapsed)
92
+ ts = format_time(elapsed)
93
+ "W#{idx} #{bar} #{done.to_s.rjust(3)}/#{total.to_s.ljust(3)} #{file.ljust(50)} #{ts}"
94
+ end
95
+
96
+ def periodic_output
97
+ now = Time.now
98
+ return if now - @last_ci_output < CI_INTERVAL
99
+ @last_ci_output = now
100
+
101
+ parts = @workers.map.with_index do |w, i|
102
+ if w[:total] > 0 && w[:done] >= w[:total]
103
+ "W#{i}: done"
104
+ else
105
+ "W#{i}: #{w[:done]}/#{w[:total]}"
106
+ end
107
+ end
108
+ elapsed = (now - @start).round(1)
109
+ $stdout.puts "[binpacker #{elapsed}s] #{parts.join(' | ')}"
110
+ end
111
+
112
+ def format_time(seconds)
113
+ return " 0.0s" if seconds < 0.001
114
+ m = (seconds / 60).floor
115
+ s = (seconds % 60).round(1)
116
+ m > 0 ? "#{m}m#{s.to_s.rjust(4, '0')}s" : "#{s.to_s.rjust(5)}s"
117
+ end
118
+ end
119
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Binpacker
4
4
  class Scheduler
5
- # Returns Array<WorkerQueue> one per worker.
6
5
  def partition(tests:, worker_count:, timings:)
7
6
  raise NotImplementedError
8
7
  end
@@ -10,28 +9,94 @@ module Binpacker
10
9
  def self.for(strategy)
11
10
  case strategy.to_s
12
11
  when "lpt" then LptScheduler.new
12
+ when "multifit" then MultifitScheduler.new
13
13
  else
14
14
  raise SchedulerError, "unknown scheduling algorithm: #{strategy}"
15
15
  end
16
16
  end
17
+
18
+ private
19
+
20
+ def weight(test, timings)
21
+ timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
22
+ end
23
+
24
+ def sorted_by_weight(tests, timings)
25
+ tests.sort_by { |t| -weight(t, timings) }
26
+ end
17
27
  end
18
28
 
19
29
  class LptScheduler < Scheduler
20
- # Longest Processing Time first.
21
- # Sort tests by descending weight, assign each to the least-loaded worker.
22
30
  def partition(tests:, worker_count:, timings:)
23
31
  queues = Array.new(worker_count) { |i| WorkerQueue.new(i) }
24
32
  loads = Array.new(worker_count, 0.0)
25
33
 
26
- # Sort by weight descending; unknown tests get default weight
27
- sorted = tests.sort_by { |t|
28
- -timings.fetch(t.key, Timing::DEFAULT_WEIGHT)
29
- }
30
-
31
- sorted.each do |test|
34
+ sorted_by_weight(tests, timings).each do |test|
32
35
  min_idx = loads.each_with_index.min_by { |load, _| load }.last
33
36
  queues[min_idx].push(test)
34
- loads[min_idx] += timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
37
+ loads[min_idx] += weight(test, timings)
38
+ end
39
+
40
+ queues
41
+ end
42
+ end
43
+
44
+ class MultifitScheduler < Scheduler
45
+ ITERATIONS = 7
46
+
47
+ def partition(tests:, worker_count:, timings:)
48
+ sorted = sorted_by_weight(tests, timings)
49
+
50
+ upper = lpt_makespan(sorted, worker_count, timings)
51
+ lower = [max_weight(sorted, timings), total_weight(sorted, timings) / worker_count.to_f].max
52
+
53
+ best_queues = nil
54
+ ITERATIONS.times do
55
+ mid = (upper + lower) / 2.0
56
+ queues = first_fit_decreasing(sorted, worker_count, mid, timings)
57
+
58
+ if queues
59
+ best_queues = queues
60
+ upper = mid
61
+ else
62
+ lower = mid
63
+ end
64
+ end
65
+
66
+ best_queues || LptScheduler.new.partition(tests: tests, worker_count: worker_count, timings: timings)
67
+ end
68
+
69
+ private
70
+
71
+ def lpt_makespan(sorted, worker_count, timings)
72
+ loads = Array.new(worker_count, 0.0)
73
+ sorted.each do |test|
74
+ min_idx = loads.each_with_index.min_by { |l, _| l }.last
75
+ loads[min_idx] += weight(test, timings)
76
+ end
77
+ loads.max
78
+ end
79
+
80
+ def max_weight(sorted, timings)
81
+ sorted.map { |t| weight(t, timings) }.max || 0.0
82
+ end
83
+
84
+ def total_weight(sorted, timings)
85
+ sorted.sum { |t| weight(t, timings) }
86
+ end
87
+
88
+ def first_fit_decreasing(sorted, worker_count, capacity, timings)
89
+ queues = Array.new(worker_count) { |i| WorkerQueue.new(i) }
90
+ loads = Array.new(worker_count, 0.0)
91
+
92
+ sorted.each do |test|
93
+ w = weight(test, timings)
94
+ idx = loads.each_with_index.find { |l, _| l + w <= capacity }&.last
95
+
96
+ return nil unless idx
97
+
98
+ queues[idx].push(test)
99
+ loads[idx] += w
35
100
  end
36
101
 
37
102
  queues
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Binpacker
4
4
  Test = Struct.new(:file, :name, keyword_init: true) do
5
- # Composite key used as timing lookup and identity.
6
5
  def key
7
6
  [file, name]
8
7
  end
@@ -15,7 +14,6 @@ module Binpacker
15
14
  @exclude = config.test_exclude
16
15
  end
17
16
 
18
- # Returns Array<Test>. Concrete strategies subclass this.
19
17
  def enumerate
20
18
  raise NotImplementedError
21
19
  end
@@ -27,21 +25,71 @@ module Binpacker
27
25
  @exclude.any? { |ex| File.fnmatch?(ex, f) }
28
26
  }
29
27
  end
28
+
29
+ def add_project_load_paths
30
+ %w[lib test].each do |path|
31
+ expanded = File.expand_path(path)
32
+ $LOAD_PATH.unshift(expanded) if Dir.exist?(expanded) && !$LOAD_PATH.include?(expanded)
33
+ end
34
+ end
30
35
  end
31
36
 
32
37
  class RSpecDiscovery < TestDiscovery
33
38
  def enumerate
34
- glob_files.map { |f| Test.new(file: f, name: f) }
39
+ if @config.respond_to?(:test_granularity) && @config.test_granularity == "example"
40
+ enumerate_examples
41
+ else
42
+ glob_files.map { |f| Test.new(file: f, name: f) }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def enumerate_examples
49
+ files = glob_files
50
+ return [] if files.empty?
51
+
52
+ examples = run_dry_run(files)
53
+ examples.map { |ex|
54
+ Test.new(
55
+ file: ex["file_path"],
56
+ name: ex["full_description"] || ex["description"]
57
+ )
58
+ }
59
+ end
60
+
61
+ def run_dry_run(files)
62
+ cmd = ["rspec", "--dry-run", "--format", "json", *files, { err: File::NULL }]
63
+ out = IO.popen(cmd) { |io| io.read }
64
+ JSON.parse(out)["examples"] || []
65
+ rescue JSON::ParserError
66
+ raise DiscoveryError, "failed to parse rspec --dry-run output"
35
67
  end
36
68
  end
37
69
 
38
70
  class MinitestDiscovery < TestDiscovery
39
- # For Minitest, we can't easily enumerate test names without running them.
40
- # Instead, treat each file as a single test unit, with the file path as the name.
41
71
  def enumerate
42
- glob_files.map { |f|
43
- Test.new(file: f, name: f)
44
- }
72
+ require "minitest"
73
+ add_project_load_paths
74
+ def Minitest.autorun; end
75
+ Minitest.seed = 42
76
+
77
+ tests = []
78
+ glob_files.each do |file|
79
+ begin
80
+ klasses_before = Minitest::Runnable.runnables.dup
81
+ load File.expand_path(file)
82
+
83
+ (Minitest::Runnable.runnables - klasses_before).each do |klass|
84
+ klass.runnable_methods.each do |method_name|
85
+ tests << Test.new(file: file, name: "#{klass}##{method_name}")
86
+ end
87
+ end
88
+ rescue => e
89
+ $stderr.puts "minitest discovery: failed to load #{file}: #{e.message}"
90
+ end
91
+ end
92
+ tests
45
93
  end
46
94
  end
47
95
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Binpacker
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -6,13 +6,14 @@ module Binpacker
6
6
  class Worker
7
7
  attr_reader :id, :status, :example_count, :passed_count
8
8
 
9
- def initialize(id, runner_class, passthrough: [])
9
+ def initialize(id, runner_class, passthrough: [], quiet: false)
10
10
  @id = id
11
11
  @runner_class = runner_class
12
12
  @passthrough = passthrough
13
+ @quiet = quiet
13
14
  @status = :created
14
15
  @timings = []
15
- @exit_code = nil
16
+ @exit_code = 0
16
17
  @example_count = 0
17
18
  @passed_count = 0
18
19
  end
@@ -27,6 +28,7 @@ module Binpacker
27
28
  @pid = Process.spawn(
28
29
  RbConfig.ruby, worker_script,
29
30
  "--runner", @runner_class.runner_name,
31
+ "--id", @id.to_s,
30
32
  *passthrough_args,
31
33
  in: @stdin_r, out: @stdout_w, err: @stderr_w,
32
34
  close_others: true
@@ -35,7 +37,7 @@ module Binpacker
35
37
  @stdin_r.close; @stdout_w.close; @stderr_w.close
36
38
 
37
39
  @stderr_thread = Thread.new do
38
- @stderr_r.each_line { |line| $stderr.write line }
40
+ @stderr_r.each_line { |line| $stderr.write line unless @quiet }
39
41
  end
40
42
 
41
43
  ready_line = read_line(timeout: 30)
@@ -59,13 +61,57 @@ module Binpacker
59
61
  @stdin_w.puts JSON.generate({ file: test.file, name: test.name })
60
62
  end
61
63
 
64
+ def batch_done
65
+ @stdin_w.puts JSON.generate({ type: "done" })
66
+ end
67
+
62
68
  def signal_done
63
69
  @stdin_w.puts JSON.generate({ type: "done" })
64
70
  @stdin_w.close
65
71
  end
66
72
 
73
+ def wait_for_batch
74
+ deadline = Time.now + 300
75
+ while Time.now < deadline
76
+ begin
77
+ line = read_line(timeout: 1)
78
+ return nil unless line
79
+ next unless line.strip.start_with?("{")
80
+ data = JSON.parse(line.strip)
81
+
82
+ if data["type"] == "timing"
83
+ @timings << { file: data["file"], name: data["name"], time: data["time"] }
84
+ elsif data["type"] == "batch_result"
85
+ @exit_code = data["passed"] ? 0 : 1
86
+ @example_count += data["total"] || 0
87
+ @passed_count += data["passed_count"] || 0
88
+ return {
89
+ timings: [],
90
+ exit_code: @exit_code,
91
+ examples: data["total"] || 0,
92
+ passed: data["passed_count"] || 0
93
+ }
94
+ elsif data["type"] == "result"
95
+ @exit_code = data["exit_code"]
96
+ @passed = data["passed"]
97
+ @example_count = data["total"] || 0
98
+ @passed_count = data["passed_count"] || 0
99
+ return {
100
+ timings: [],
101
+ exit_code: @exit_code,
102
+ examples: @example_count,
103
+ passed: @passed_count
104
+ }
105
+ end
106
+ rescue JSON::ParserError
107
+ end
108
+ end
109
+ nil
110
+ end
111
+
67
112
  def collect_results
68
113
  @status = :running
114
+
69
115
  @stdout_r.each_line do |line|
70
116
  data = JSON.parse(line.strip)
71
117
  case data["type"]
@@ -76,6 +122,13 @@ module Binpacker
76
122
  @passed = data["passed"]
77
123
  @example_count = data["total"] || 0
78
124
  @passed_count = data["passed_count"] || 0
125
+ break
126
+ when "batch_result"
127
+ @exit_code = data["passed"] ? 0 : 1
128
+ @passed = data["passed"]
129
+ @example_count = data["total"] || 0
130
+ @passed_count = data["passed_count"] || 0
131
+ break
79
132
  when "output"
80
133
  $stdout.write data["text"] if data["text"]
81
134
  end
@@ -109,7 +162,9 @@ module Binpacker
109
162
 
110
163
  def passthrough_args
111
164
  return [] if @passthrough.empty?
112
- @passthrough.flat_map { |arg| ["--rspec-arg", arg] }
165
+
166
+ worker_arg = @runner_class.runner_name == "minitest" ? "--minitest-arg" : "--rspec-arg"
167
+ @passthrough.flat_map { |arg| [worker_arg, arg] }
113
168
  end
114
169
 
115
170
  def kill!
data/lib/binpacker.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "binpacker/worker"
10
10
  require_relative "binpacker/test_runner"
11
11
  require_relative "binpacker/calibration"
12
12
  require_relative "binpacker/orchestrator"
13
+ require_relative "binpacker/progress"
13
14
 
14
15
  module Binpacker
15
16
  Error = Class.new(StandardError)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: binpacker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - megurine
@@ -24,6 +24,7 @@ files:
24
24
  - lib/binpacker/cli.rb
25
25
  - lib/binpacker/config.rb
26
26
  - lib/binpacker/orchestrator.rb
27
+ - lib/binpacker/progress.rb
27
28
  - lib/binpacker/scheduler.rb
28
29
  - lib/binpacker/test_discovery.rb
29
30
  - lib/binpacker/test_runner.rb