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 +4 -4
- data/exe/binpacker-worker +163 -43
- data/lib/binpacker/calibration.rb +1 -1
- data/lib/binpacker/cli.rb +65 -3
- data/lib/binpacker/config.rb +2 -0
- data/lib/binpacker/orchestrator.rb +188 -15
- data/lib/binpacker/progress.rb +119 -0
- data/lib/binpacker/scheduler.rb +75 -10
- data/lib/binpacker/test_discovery.rb +56 -8
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker.rb +59 -4
- data/lib/binpacker.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfee21fa79b8b157afbe89bba482a2a8c2b95c4ec22dbff36117a269275f47ef
|
|
4
|
+
data.tar.gz: 636266dc852e315dffb3e1b815f39b615e4a73be4cfa9dac445a01ef7b7d76fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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",
|
|
24
|
+
"--format", "progress", "--out", progress_outfile.path,
|
|
15
25
|
*rspec_args,
|
|
16
26
|
*files
|
|
17
27
|
]
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
68
|
-
$stdout.puts JSON.generate({ type: "
|
|
171
|
+
loop do
|
|
172
|
+
$stdout.puts JSON.generate({ type: "ready" })
|
|
69
173
|
$stdout.flush
|
|
70
|
-
exit 0
|
|
71
|
-
end
|
|
72
174
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
$stderr.puts "[worker-#{wid}]
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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]}
|
|
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]}
|
|
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
|
data/lib/binpacker/config.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
data/lib/binpacker/scheduler.rb
CHANGED
|
@@ -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
|
-
|
|
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] +=
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
data/lib/binpacker/version.rb
CHANGED
data/lib/binpacker/worker.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
|
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
|