binpacker 0.1.0 → 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.
- checksums.yaml +4 -4
- data/exe/binpacker-worker +163 -43
- data/lib/binpacker/calibration.rb +20 -8
- data/lib/binpacker/cli.rb +192 -8
- data/lib/binpacker/config.rb +4 -1
- data/lib/binpacker/orchestrator.rb +208 -15
- data/lib/binpacker/progress.rb +117 -0
- data/lib/binpacker/project_state.rb +82 -0
- data/lib/binpacker/report.rb +102 -0
- data/lib/binpacker/scheduler.rb +75 -10
- data/lib/binpacker/skills.rb +36 -0
- data/lib/binpacker/test_discovery.rb +62 -8
- data/lib/binpacker/timing.rb +5 -0
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker.rb +59 -4
- data/lib/binpacker.rb +4 -0
- data/sig/binpacker/calibration.rbs +6 -0
- data/sig/binpacker/cli.rbs +7 -0
- data/sig/binpacker/config.rbs +13 -0
- data/sig/binpacker/orchestrator.rbs +6 -0
- data/sig/binpacker/progress.rbs +5 -0
- data/sig/binpacker/project_state.rbs +16 -0
- data/sig/binpacker/report.rbs +10 -0
- data/sig/binpacker/scheduler.rbs +5 -0
- data/sig/binpacker/skills.rbs +10 -0
- data/sig/binpacker/test_discovery.rbs +15 -0
- data/sig/binpacker/test_runner.rbs +13 -0
- data/sig/binpacker/timing.rbs +11 -0
- data/sig/binpacker/worker.rbs +12 -0
- data/sig/binpacker/worker_queue.rbs +6 -0
- data/skills/binpacker-improve/SKILL.md +52 -0
- data/skills/binpacker-setup/SKILL.md +76 -0
- metadata +25 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 991ac5d941b69573b4f6d03c211ff862465a91ee31ddd82f5ce8d8150a2d4f0d
|
|
4
|
+
data.tar.gz: 8ad2f245f73a46a4fdd64272049835c36d3b2dc4a0bb5df9cf67f851789ae59a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4345168c76cd0e072ab8f0775df54fe705e13f873a1f7f8ce764b897f42f91f75c5b4b0dee07329b577200230a7707a90284e73b554a339798575e084ce93f15
|
|
7
|
+
data.tar.gz: 40408cf8b88b878033226df711fbf3e57c217293b85b6eb7f94f55289eb7740d02dcc66598c5f594a3cd21821b5186eb38942c7e03747838a6487ccc37e018d1
|
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
|
|
@@ -13,10 +13,13 @@ module Binpacker
|
|
|
13
13
|
@timing = Timing.new(config.timing_file)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# Runs each Test serially and appends its measured Weight to the Timing file.
|
|
17
|
+
# With incremental: true, Tests that already have a measured Weight are skipped.
|
|
18
|
+
def run(tests, incremental: false)
|
|
19
|
+
targets = incremental ? unmeasured(tests) : tests
|
|
17
20
|
results = []
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
targets.each do |test|
|
|
20
23
|
elapsed = run_single(test)
|
|
21
24
|
results << { file: test.file, name: test.name, time: elapsed }
|
|
22
25
|
end
|
|
@@ -27,21 +30,30 @@ module Binpacker
|
|
|
27
30
|
|
|
28
31
|
private
|
|
29
32
|
|
|
33
|
+
def unmeasured(tests)
|
|
34
|
+
measured = @timing.load_raw
|
|
35
|
+
tests.reject { |test| measured.key?([@timing.normalize_path(test.file), test.name]) }
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
def run_single(test)
|
|
31
39
|
outfile = Tempfile.new("binpacker-cal")
|
|
32
40
|
outfile.close
|
|
33
41
|
|
|
34
42
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
43
|
|
|
44
|
+
# At file granularity the Test's name is its file path (see Test
|
|
45
|
+
# discovery), so there is no single example/method to filter — run the
|
|
46
|
+
# whole file. A per-test filter would match nothing and measure only
|
|
47
|
+
# load time.
|
|
48
|
+
file_unit = test.name == test.file
|
|
49
|
+
|
|
36
50
|
case @config.test_runner
|
|
37
51
|
when "rspec"
|
|
38
|
-
cmd = [
|
|
39
|
-
|
|
40
|
-
"--example", test.name,
|
|
41
|
-
"--format", "json", "--out", outfile.path
|
|
42
|
-
]
|
|
52
|
+
cmd = ["rspec", test.file, "--format", "json", "--out", outfile.path]
|
|
53
|
+
cmd.push("--example", test.name) unless file_unit
|
|
43
54
|
when "minitest"
|
|
44
|
-
cmd = ["ruby", "-Ilib:test", test.file
|
|
55
|
+
cmd = ["ruby", "-Ilib:test", test.file]
|
|
56
|
+
cmd.push("--name", "/^#{Regexp.escape(test.name)}$/") unless file_unit
|
|
45
57
|
else
|
|
46
58
|
raise ConfigError, "unsupported runner for calibration: #{@config.test_runner}"
|
|
47
59
|
end
|
data/lib/binpacker/cli.rb
CHANGED
|
@@ -14,6 +14,13 @@ module Binpacker
|
|
|
14
14
|
@command = nil
|
|
15
15
|
@profile = nil
|
|
16
16
|
@passthrough = []
|
|
17
|
+
@quiet = false
|
|
18
|
+
@incremental = false
|
|
19
|
+
@report_path = nil
|
|
20
|
+
@skill_list = false
|
|
21
|
+
@skill_path = false
|
|
22
|
+
@skill_describe = false
|
|
23
|
+
@skill_name = nil
|
|
17
24
|
parse!
|
|
18
25
|
end
|
|
19
26
|
|
|
@@ -23,6 +30,12 @@ module Binpacker
|
|
|
23
30
|
cmd_calibrate
|
|
24
31
|
when "run"
|
|
25
32
|
cmd_run
|
|
33
|
+
when "init"
|
|
34
|
+
cmd_init
|
|
35
|
+
when "skill"
|
|
36
|
+
cmd_skill
|
|
37
|
+
when "describe"
|
|
38
|
+
cmd_describe
|
|
26
39
|
when "--version", "-v"
|
|
27
40
|
puts "binpacker #{Binpacker::VERSION}"
|
|
28
41
|
when "--help", "-h", nil
|
|
@@ -51,6 +64,30 @@ module Binpacker
|
|
|
51
64
|
opts.on("--help", "Show help") do
|
|
52
65
|
@command ||= "--help"
|
|
53
66
|
end
|
|
67
|
+
|
|
68
|
+
opts.on("--quiet", "Suppress worker output") do
|
|
69
|
+
@quiet = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
opts.on("--incremental", "Calibrate only tests without timing data") do
|
|
73
|
+
@incremental = true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
opts.on("--report PATH", "(run) Write a JSON run report to PATH") do |v|
|
|
77
|
+
@report_path = v
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
opts.on("--list", "(skill) List bundled skills") do
|
|
81
|
+
@skill_list = true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
opts.on("--path", "(skill) Print the SKILL.md path for <name>") do
|
|
85
|
+
@skill_path = true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
opts.on("--describe", "(skill) Report project state and next skill") do
|
|
89
|
+
@skill_describe = true
|
|
90
|
+
end
|
|
54
91
|
end
|
|
55
92
|
|
|
56
93
|
# Extract passthrough arguments after "--"
|
|
@@ -60,7 +97,50 @@ module Binpacker
|
|
|
60
97
|
end
|
|
61
98
|
|
|
62
99
|
remaining = parser.parse(@args)
|
|
63
|
-
|
|
100
|
+
# A positional command wins; otherwise keep any command an option set
|
|
101
|
+
# (e.g. --version), so `binpacker --version` isn't clobbered to nil.
|
|
102
|
+
@command = remaining.shift || @command
|
|
103
|
+
@skill_name = remaining.shift
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def cmd_init
|
|
107
|
+
config_path = Pathname.pwd.join("binpacker.yml")
|
|
108
|
+
if config_path.exist?
|
|
109
|
+
puts "binpacker.yml already exists at #{config_path}"
|
|
110
|
+
exit 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
framework = detect_framework
|
|
114
|
+
unless ProjectState::SUPPORTED_FRAMEWORKS.include?(framework)
|
|
115
|
+
$stderr.puts "Detected #{framework} — binpacker supports rspec and minitest only."
|
|
116
|
+
$stderr.puts "No binpacker.yml was created."
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
pattern = framework == "minitest" ? "test/**/*_test.rb" : "spec/**/*_spec.rb"
|
|
120
|
+
runner = framework
|
|
121
|
+
|
|
122
|
+
yaml = <<~YAML
|
|
123
|
+
profiles:
|
|
124
|
+
default:
|
|
125
|
+
test_runner: #{runner}
|
|
126
|
+
workers: auto
|
|
127
|
+
timing_file: binpacker.timings
|
|
128
|
+
test_pattern: "#{pattern}"
|
|
129
|
+
scheduler:
|
|
130
|
+
algorithm: multifit
|
|
131
|
+
steal_enabled: true
|
|
132
|
+
ci:
|
|
133
|
+
extends: default
|
|
134
|
+
workers: 4
|
|
135
|
+
YAML
|
|
136
|
+
|
|
137
|
+
config_path.write(yaml)
|
|
138
|
+
puts "Created #{config_path}"
|
|
139
|
+
puts "Detected test framework: #{framework}"
|
|
140
|
+
puts ""
|
|
141
|
+
puts "Next steps:"
|
|
142
|
+
puts " 1. binpacker calibrate (seed timing data)"
|
|
143
|
+
puts " 2. binpacker run (run in parallel)"
|
|
64
144
|
end
|
|
65
145
|
|
|
66
146
|
def cmd_calibrate
|
|
@@ -68,32 +148,121 @@ module Binpacker
|
|
|
68
148
|
discovery_klass = config.test_runner == "rspec" ? RSpecDiscovery : MinitestDiscovery
|
|
69
149
|
tests = discovery_klass.new(config).enumerate
|
|
70
150
|
|
|
71
|
-
puts "Calibrating #{tests.size} tests..."
|
|
72
151
|
cal = Calibration.new(config)
|
|
73
|
-
|
|
152
|
+
|
|
153
|
+
if @incremental
|
|
154
|
+
puts "Calibrating (incremental) #{tests.size} tests..."
|
|
155
|
+
else
|
|
156
|
+
puts "Calibrating #{tests.size} tests..."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
timings = cal.run(tests, incremental: @incremental)
|
|
74
160
|
|
|
75
161
|
total = timings.sum { |t| t[:time] }
|
|
76
|
-
|
|
162
|
+
measured = timings.size
|
|
163
|
+
skipped = tests.size - measured
|
|
164
|
+
summary = "Calibration complete: #{measured} #{pluralize(measured, 'test')} in #{total.round(2)}s"
|
|
165
|
+
summary += " (#{skipped} skipped, already measured)" if @incremental && skipped.positive?
|
|
166
|
+
puts summary
|
|
77
167
|
puts "Timing data written to #{config.timing_file}"
|
|
78
168
|
end
|
|
79
169
|
|
|
170
|
+
def cmd_skill
|
|
171
|
+
return cmd_describe if @skill_describe
|
|
172
|
+
|
|
173
|
+
if @skill_path
|
|
174
|
+
path = Skills.path(@skill_name)
|
|
175
|
+
if path
|
|
176
|
+
puts path
|
|
177
|
+
else
|
|
178
|
+
$stderr.puts "unknown skill: #{@skill_name}"
|
|
179
|
+
exit 1
|
|
180
|
+
end
|
|
181
|
+
elsif @skill_name && !@skill_list
|
|
182
|
+
print_skill(@skill_name)
|
|
183
|
+
else
|
|
184
|
+
list_skills
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def cmd_describe
|
|
189
|
+
state = ProjectState.new
|
|
190
|
+
puts "binpacker project state:"
|
|
191
|
+
puts " config (binpacker.yml): #{state.config_present? ? 'present' : 'missing'}"
|
|
192
|
+
puts " timing data: #{state.timing_present? ? 'present' : 'missing'}"
|
|
193
|
+
puts " test framework: #{state.framework || 'not detected'}"
|
|
194
|
+
puts " CI wired for binpacker: #{state.ci_wired? ? 'yes' : 'no'}"
|
|
195
|
+
puts ""
|
|
196
|
+
|
|
197
|
+
unless state.supported_framework?
|
|
198
|
+
puts "binpacker supports rspec and minitest; #{state.framework} is not supported yet."
|
|
199
|
+
puts "Setup cannot proceed against a #{state.framework} suite."
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
rec = state.recommendation
|
|
204
|
+
puts "Recommended next skill: #{rec}"
|
|
205
|
+
puts "Run `binpacker skill #{rec}` to load its instructions."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def list_skills
|
|
209
|
+
skills = Skills.list
|
|
210
|
+
if skills.empty?
|
|
211
|
+
puts "No bundled skills found."
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
puts "Bundled skills:"
|
|
215
|
+
skills.each { |s| puts " #{s[:name]}" }
|
|
216
|
+
puts ""
|
|
217
|
+
puts "Print a skill's instructions with `binpacker skill <name>`."
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def print_skill(name)
|
|
221
|
+
unless Skills.exist?(name)
|
|
222
|
+
$stderr.puts "unknown skill: #{name}"
|
|
223
|
+
$stderr.puts "Run `binpacker skill` to list bundled skills."
|
|
224
|
+
exit 1
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
puts "# Skill: #{name}"
|
|
228
|
+
puts "# Source: #{Skills.path(name)}"
|
|
229
|
+
puts ""
|
|
230
|
+
puts Skills.body(name)
|
|
231
|
+
end
|
|
232
|
+
|
|
80
233
|
def cmd_run
|
|
81
234
|
config = Config.new(profile: @profile)
|
|
82
|
-
|
|
235
|
+
report_path = @report_path || config.report_file
|
|
236
|
+
orchestrator = Orchestrator.new(
|
|
237
|
+
config, passthrough: @passthrough, quiet: @quiet, report_path: report_path
|
|
238
|
+
)
|
|
83
239
|
|
|
84
240
|
puts "binpacker starting (#{config.worker_count} workers, profile: #{config.profile})"
|
|
85
241
|
result = orchestrator.run
|
|
242
|
+
puts "Run report written to #{report_path}" if report_path
|
|
243
|
+
unit = test_unit_label(config)
|
|
86
244
|
|
|
87
245
|
if result[:passed]
|
|
88
|
-
puts "All #{result[:total]}
|
|
246
|
+
puts "All #{result[:total]} #{pluralize(result[:total], unit)} passed across #{config.worker_count} workers."
|
|
89
247
|
exit 0
|
|
248
|
+
elsif result[:empty_filter]
|
|
249
|
+
puts "No tests matched the Minitest filter."
|
|
250
|
+
exit 1
|
|
90
251
|
else
|
|
91
252
|
failed = result[:total] - result[:passed_count]
|
|
92
|
-
puts "#{failed}/#{result[:total]}
|
|
253
|
+
puts "#{failed}/#{result[:total]} #{pluralize(failed, unit)} failed."
|
|
93
254
|
exit 1
|
|
94
255
|
end
|
|
95
256
|
end
|
|
96
257
|
|
|
258
|
+
def test_unit_label(config)
|
|
259
|
+
config.test_runner == "rspec" ? "example" : "test"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def pluralize(count, word)
|
|
263
|
+
count == 1 ? word : "#{word}s"
|
|
264
|
+
end
|
|
265
|
+
|
|
97
266
|
def print_help
|
|
98
267
|
puts <<~HELP
|
|
99
268
|
binpacker #{Binpacker::VERSION}
|
|
@@ -101,16 +270,31 @@ module Binpacker
|
|
|
101
270
|
Commands:
|
|
102
271
|
run Execute tests across worker processes
|
|
103
272
|
calibrate Run tests serially to generate timing data
|
|
273
|
+
init Create binpacker.yml with auto-detected settings
|
|
274
|
+
describe Report project state and recommend the next skill
|
|
275
|
+
skill List or print bundled agent skills
|
|
104
276
|
|
|
105
277
|
Options:
|
|
106
278
|
--profile NAME Select profile from binpacker.yml
|
|
279
|
+
--incremental (calibrate) Measure only tests without timing data
|
|
280
|
+
--report PATH (run) Write a JSON run report to PATH
|
|
281
|
+
--list (skill) List bundled skills
|
|
282
|
+
--path (skill) Print the SKILL.md path for <name>
|
|
283
|
+
--describe (skill) Report project state and next skill
|
|
107
284
|
--help Show this message
|
|
108
285
|
|
|
109
286
|
Examples:
|
|
287
|
+
binpacker init
|
|
110
288
|
binpacker run --profile ci
|
|
111
289
|
binpacker run -- --tag ~slow
|
|
112
|
-
binpacker calibrate
|
|
290
|
+
binpacker calibrate --incremental
|
|
291
|
+
binpacker describe
|
|
292
|
+
binpacker skill binpacker-setup
|
|
113
293
|
HELP
|
|
114
294
|
end
|
|
295
|
+
|
|
296
|
+
def detect_framework
|
|
297
|
+
ProjectState.new.framework || "rspec"
|
|
298
|
+
end
|
|
115
299
|
end
|
|
116
300
|
end
|
data/lib/binpacker/config.rb
CHANGED
|
@@ -10,13 +10,15 @@ 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" => [],
|
|
17
|
+
"report_file" => nil,
|
|
16
18
|
"scheduler" => {
|
|
17
19
|
"strategy" => "static",
|
|
18
20
|
"steal_enabled" => false,
|
|
19
|
-
"algorithm" => "
|
|
21
|
+
"algorithm" => "multifit"
|
|
20
22
|
}
|
|
21
23
|
}.freeze
|
|
22
24
|
|
|
@@ -77,6 +79,7 @@ module Binpacker
|
|
|
77
79
|
|
|
78
80
|
def build_profile(name)
|
|
79
81
|
profiles = @raw.fetch("profiles", {})
|
|
82
|
+
return DEFAULTS.dup if profiles.empty? && name == "default"
|
|
80
83
|
entry = profiles[name]
|
|
81
84
|
raise ConfigError, "profile '#{name}' not found in binpacker.yml" unless entry
|
|
82
85
|
parent = entry["extends"]
|