rspec-turbo 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +287 -0
- data/exe/rspec-turbo +6 -0
- data/lib/rspec-turbo.rb +4 -0
- data/lib/rspec_turbo/batch_planner.rb +127 -0
- data/lib/rspec_turbo/config.rb +63 -0
- data/lib/rspec_turbo/db_setup.rb +86 -0
- data/lib/rspec_turbo/display.rb +231 -0
- data/lib/rspec_turbo/executor.rb +191 -0
- data/lib/rspec_turbo/file_discovery.rb +53 -0
- data/lib/rspec_turbo/options.rb +71 -0
- data/lib/rspec_turbo/progress_reporter.rb +32 -0
- data/lib/rspec_turbo/railtie.rb +16 -0
- data/lib/rspec_turbo/runner.rb +143 -0
- data/lib/rspec_turbo/slow_profile.rb +208 -0
- data/lib/rspec_turbo/tasks.rake +46 -0
- data/lib/rspec_turbo/terminal.rb +28 -0
- data/lib/rspec_turbo/version.rb +5 -0
- data/lib/rspec_turbo/worker.rb +88 -0
- data/lib/rspec_turbo.rb +25 -0
- metadata +155 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RSpecTurbo
|
|
6
|
+
# Top-level orchestration: parse argv, set up the test databases, discover
|
|
7
|
+
# and plan the spec files, hand execution to the Executor, then print the
|
|
8
|
+
# report and (optionally) merge coverage. Exits non-zero on any failure.
|
|
9
|
+
class Runner
|
|
10
|
+
def initialize(argv)
|
|
11
|
+
@options = Options.new(argv)
|
|
12
|
+
@workers = Config.workers
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
FileUtils.mkdir_p(Config.log_dir)
|
|
17
|
+
print_header
|
|
18
|
+
|
|
19
|
+
setup_databases
|
|
20
|
+
planner = plan(discover_files)
|
|
21
|
+
|
|
22
|
+
FileUtils.rm_rf("coverage") if Config.coverage?
|
|
23
|
+
|
|
24
|
+
display = Display.new(planner)
|
|
25
|
+
executor = Executor.new(planner, display, @options.rspec_options)
|
|
26
|
+
results = executor.run
|
|
27
|
+
wall_total = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - executor.wall_started).round
|
|
28
|
+
|
|
29
|
+
display.print_report(results, wall_total, @workers)
|
|
30
|
+
merge_coverage if Config.coverage?
|
|
31
|
+
|
|
32
|
+
exit((results.any? { |r| r[:status] == "FAIL" }) ? 1 : 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def print_header
|
|
38
|
+
puts
|
|
39
|
+
puts Terminal::SEP_THICK
|
|
40
|
+
puts " RSpec Turbo - Parallel"
|
|
41
|
+
puts Terminal::SEP_THICK
|
|
42
|
+
puts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def setup_databases
|
|
46
|
+
setup = DbSetup.new(@workers)
|
|
47
|
+
cached = !Config.force_setup? && setup.cached?
|
|
48
|
+
label = cached ? "DB cache hit" : "Setting up #{@workers} test DB(s)"
|
|
49
|
+
|
|
50
|
+
ok, elapsed = with_spinner(label) { setup.run! }
|
|
51
|
+
|
|
52
|
+
unless ok
|
|
53
|
+
print "\e[?25h" if Config::TTY
|
|
54
|
+
warn "✗ DB setup failed."
|
|
55
|
+
exit 2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
puts " #{Terminal.c("32", "✓")} #{@workers} DB(s) ready (#{Terminal.fmt_duration(elapsed)})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def discover_files
|
|
62
|
+
files = FileDiscovery.new(@options.folders, exclude_patterns: @options.exclude_patterns).files
|
|
63
|
+
|
|
64
|
+
if files.empty?
|
|
65
|
+
puts "Nothing to run."
|
|
66
|
+
exit 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
files
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def plan(files)
|
|
73
|
+
planner = nil
|
|
74
|
+
|
|
75
|
+
_, elapsed = with_spinner("Counting examples (#{files.size} files)") do
|
|
76
|
+
planner = BatchPlanner.new(files, num_workers: @workers, rspec_options: @options.rspec_options).plan!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
total = planner.counts.values.sum
|
|
80
|
+
avg = planner.batches.empty? ? 0 : (total.to_f / planner.batches.size).round
|
|
81
|
+
pending_str = planner.pending_count.positive? ? " · #{planner.pending_count} pending" : ""
|
|
82
|
+
|
|
83
|
+
puts " #{Terminal.c("32", "✓")} #{total} examples#{pending_str} · #{files.size} files · " \
|
|
84
|
+
"#{planner.batches.size} batches (~#{avg} each) (#{Terminal.fmt_duration(elapsed)})"
|
|
85
|
+
puts
|
|
86
|
+
|
|
87
|
+
planner
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def merge_coverage
|
|
91
|
+
puts
|
|
92
|
+
print " Merging coverage reports..."
|
|
93
|
+
$stdout.flush
|
|
94
|
+
|
|
95
|
+
merge_log = Config.coverage_merge_log
|
|
96
|
+
ok = system("RAILS_ENV=test bundle exec rake coverage:merge", out: merge_log, err: [:child, :out])
|
|
97
|
+
|
|
98
|
+
if ok
|
|
99
|
+
puts " #{Terminal.c("32", "✓")} done"
|
|
100
|
+
else
|
|
101
|
+
puts " #{Terminal.c("31", "✗")} failed (run `rake coverage:merge` manually)"
|
|
102
|
+
last_lines = File.exist?(merge_log) ? File.readlines(merge_log).last(10).join.strip : ""
|
|
103
|
+
warn last_lines unless last_lines.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Animated single-line spinner wrapping a block. On a TTY it shows a
|
|
108
|
+
# spinning animation and clears it on completion; on CI it just runs the
|
|
109
|
+
# block. Returns [block_result, elapsed_seconds].
|
|
110
|
+
def with_spinner(label)
|
|
111
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
112
|
+
|
|
113
|
+
unless Config::TTY
|
|
114
|
+
result = yield
|
|
115
|
+
|
|
116
|
+
return [result, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
frame = 0
|
|
120
|
+
running = true
|
|
121
|
+
print "\e[?25l"
|
|
122
|
+
|
|
123
|
+
thread = Thread.new do
|
|
124
|
+
while running
|
|
125
|
+
spin = Terminal::SPINNER_FRAMES[frame % Terminal::SPINNER_FRAMES.size]
|
|
126
|
+
print "\r \e[36m#{spin}\e[0m #{label}..."
|
|
127
|
+
$stdout.flush
|
|
128
|
+
sleep 0.1
|
|
129
|
+
frame += 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result = yield
|
|
134
|
+
running = false
|
|
135
|
+
thread.join
|
|
136
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round
|
|
137
|
+
print "\r\e[2K"
|
|
138
|
+
print "\e[?25h"
|
|
139
|
+
|
|
140
|
+
[result, elapsed]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in slow-test profiler, loaded into each worker by Worker.spawn.
|
|
4
|
+
#
|
|
5
|
+
# Enable with RSPEC_PROFILE_SLOW=1 to print, at the end of the run:
|
|
6
|
+
# - Top 20 slowest / query-heaviest examples
|
|
7
|
+
# - TOP 15 FILES BY TIME (parsed back by RSpecTurbo::Display for the report)
|
|
8
|
+
#
|
|
9
|
+
# Optional thresholds:
|
|
10
|
+
# RSPEC_PROFILE_THRESHOLD_TIME=0.2 # seconds; example must exceed to list
|
|
11
|
+
# RSPEC_PROFILE_THRESHOLD_QUERIES=30 # SQL queries; same idea
|
|
12
|
+
#
|
|
13
|
+
# Optional folder grouping:
|
|
14
|
+
# RSPEC_PROFILE_GROUP_BY=1
|
|
15
|
+
# Auto-detects the base from the rspec CLI args. Running `rspec
|
|
16
|
+
# spec/requests/v1` buckets examples by direct subfolder of that base.
|
|
17
|
+
# With multiple paths it uses their longest common directory.
|
|
18
|
+
# RSPEC_PROFILE_GROUP_BY=spec/requests/v1
|
|
19
|
+
# Explicit base path. Buckets by direct subfolder of that base.
|
|
20
|
+
# RSPEC_PROFILE_GROUP_BY=spec/requests/v1/items,spec/requests/v1/bins
|
|
21
|
+
# Explicit list of folders; each is its own bucket.
|
|
22
|
+
#
|
|
23
|
+
# Query counting relies on ActiveSupport::Notifications, so this block only
|
|
24
|
+
# does work in a Rails app and only when explicitly enabled.
|
|
25
|
+
|
|
26
|
+
return unless ENV["RSPEC_PROFILE_SLOW"] || ENV["RSPEC_PROFILE_GROUP_BY"]
|
|
27
|
+
|
|
28
|
+
RSpec.configure do |config|
|
|
29
|
+
slow_tests = []
|
|
30
|
+
file_times = Hash.new(0.0)
|
|
31
|
+
file_query_counts = Hash.new(0)
|
|
32
|
+
folder_times = Hash.new(0.0)
|
|
33
|
+
folder_query_counts = Hash.new(0)
|
|
34
|
+
folder_example_counts = Hash.new(0)
|
|
35
|
+
|
|
36
|
+
threshold_time = Float(ENV["RSPEC_PROFILE_THRESHOLD_TIME"] || "0.2")
|
|
37
|
+
threshold_queries = Integer(ENV["RSPEC_PROFILE_THRESHOLD_QUERIES"] || "30")
|
|
38
|
+
|
|
39
|
+
blank = ->(str) { str.nil? || str.strip.empty? }
|
|
40
|
+
|
|
41
|
+
# Resolve grouping config from env into one of:
|
|
42
|
+
# { mode: :subfolder, base: "spec/requests/v1" } - bucket by direct subfolder
|
|
43
|
+
# { mode: :list, bases: ["spec/foo", "spec/bar"] } - each folder is its own bucket
|
|
44
|
+
resolve_group_by = lambda do |raw|
|
|
45
|
+
next nil if blank.call(raw)
|
|
46
|
+
|
|
47
|
+
raw = raw.delete_suffix("/")
|
|
48
|
+
|
|
49
|
+
if raw.include?(",")
|
|
50
|
+
bases = raw.split(",").map { |p| p.strip.delete_prefix("./").delete_suffix("/") }.reject(&:empty?)
|
|
51
|
+
next nil if bases.empty?
|
|
52
|
+
|
|
53
|
+
next {mode: :list, bases: bases}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if ["1", "true", "auto"].include?(raw.downcase)
|
|
57
|
+
cli_paths = RSpec.configuration.files_to_run
|
|
58
|
+
next nil if cli_paths.empty?
|
|
59
|
+
|
|
60
|
+
dirs = cli_paths.map do |path|
|
|
61
|
+
cleaned = path.delete_prefix("./").sub(/:\d+\z/, "")
|
|
62
|
+
File.directory?(cleaned) ? cleaned : File.dirname(cleaned)
|
|
63
|
+
end.uniq
|
|
64
|
+
|
|
65
|
+
base =
|
|
66
|
+
if dirs.size == 1
|
|
67
|
+
dirs.first
|
|
68
|
+
else
|
|
69
|
+
parts = dirs.map { |d| d.split("/") }
|
|
70
|
+
common = []
|
|
71
|
+
parts.first.each_with_index do |segment, i|
|
|
72
|
+
break unless parts.all? { |p| p[i] == segment }
|
|
73
|
+
|
|
74
|
+
common << segment
|
|
75
|
+
end
|
|
76
|
+
common.empty? ? nil : common.join("/")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
next nil if base.nil?
|
|
80
|
+
|
|
81
|
+
next {mode: :subfolder, base: base}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
{mode: :subfolder, base: raw}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
bucket_for = lambda do |file_path, group_config|
|
|
88
|
+
next nil unless group_config
|
|
89
|
+
|
|
90
|
+
normalized = file_path.to_s.delete_prefix("./")
|
|
91
|
+
|
|
92
|
+
case group_config[:mode]
|
|
93
|
+
when :subfolder
|
|
94
|
+
base = group_config[:base]
|
|
95
|
+
next nil unless normalized.start_with?("#{base}/")
|
|
96
|
+
|
|
97
|
+
rest = normalized[(base.length + 1)..]
|
|
98
|
+
first = rest.split("/").first
|
|
99
|
+
first ? "#{base}/#{first}" : nil
|
|
100
|
+
when :list
|
|
101
|
+
group_config[:bases].find { |b| normalized.start_with?("#{b}/") || normalized == b }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
group_config = nil
|
|
106
|
+
|
|
107
|
+
config.before(:suite) do
|
|
108
|
+
group_config = resolve_group_by.call(ENV["RSPEC_PROFILE_GROUP_BY"])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Query counting needs ActiveSupport; without it (non-Rails suites) we still
|
|
112
|
+
# profile by time and just report zero queries instead of crashing.
|
|
113
|
+
count_sql = defined?(ActiveSupport::Notifications)
|
|
114
|
+
|
|
115
|
+
config.around(:each) do |example|
|
|
116
|
+
query_count = 0
|
|
117
|
+
subscriber =
|
|
118
|
+
if count_sql
|
|
119
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, payload|
|
|
120
|
+
next if payload[:name] == "SCHEMA"
|
|
121
|
+
next if /\A\s*(SAVEPOINT|RELEASE|ROLLBACK|BEGIN|COMMIT)/i.match?(payload[:sql])
|
|
122
|
+
|
|
123
|
+
query_count += 1
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
128
|
+
example.run
|
|
129
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
130
|
+
|
|
131
|
+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
132
|
+
|
|
133
|
+
file = example.metadata[:file_path]
|
|
134
|
+
file_times[file] += duration
|
|
135
|
+
file_query_counts[file] += query_count
|
|
136
|
+
|
|
137
|
+
if group_config
|
|
138
|
+
folder_key = bucket_for.call(file, group_config)
|
|
139
|
+
|
|
140
|
+
if folder_key
|
|
141
|
+
folder_times[folder_key] += duration
|
|
142
|
+
folder_query_counts[folder_key] += query_count
|
|
143
|
+
folder_example_counts[folder_key] += 1
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
next unless duration >= threshold_time || query_count >= threshold_queries
|
|
148
|
+
|
|
149
|
+
slow_tests << {
|
|
150
|
+
description: example.full_description,
|
|
151
|
+
duration: duration,
|
|
152
|
+
queries: query_count,
|
|
153
|
+
file: file
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
config.after(:suite) do
|
|
158
|
+
puts
|
|
159
|
+
puts
|
|
160
|
+
puts "#{"\e[34m=" * 96}\e[0m"
|
|
161
|
+
puts " \e[34mSLOW / EXPENSIVE EXAMPLES (time >= #{threshold_time}s OR queries >= #{threshold_queries})\e[0m"
|
|
162
|
+
puts "#{"\e[34m=" * 96}\e[0m"
|
|
163
|
+
|
|
164
|
+
puts "TIME QUERIES FILE EXAMPLE"
|
|
165
|
+
|
|
166
|
+
slow_tests.sort_by { |t| -t[:duration] }.first(20).each do |t|
|
|
167
|
+
puts format(
|
|
168
|
+
"%6.3fs %4d %-60s %s",
|
|
169
|
+
t[:duration],
|
|
170
|
+
t[:queries],
|
|
171
|
+
t[:file].to_s.sub(%r{^\./}, "").slice(0, 60),
|
|
172
|
+
t[:description].slice(0, 80)
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
puts
|
|
177
|
+
puts "#{"\e[31m=" * 96}\e[0m"
|
|
178
|
+
puts " \e[31mTOP 15 FILES BY TIME\e[0m"
|
|
179
|
+
puts "#{"\e[31m=" * 96}\e[0m"
|
|
180
|
+
|
|
181
|
+
puts "TIME QUERIES FILE"
|
|
182
|
+
|
|
183
|
+
file_times.sort_by { |_, t| -t }.first(15).each do |file, time|
|
|
184
|
+
puts format("%6.2fs %6d %s", time, file_query_counts[file], file.to_s.sub(%r{^\./}, ""))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if group_config && folder_times.size > 1
|
|
188
|
+
label =
|
|
189
|
+
case group_config[:mode]
|
|
190
|
+
when :subfolder then "FOLDERS UNDER #{group_config[:base]} BY TIME"
|
|
191
|
+
when :list then "FOLDERS BY TIME"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
puts
|
|
195
|
+
puts "#{"\e[33m=" * 96}\e[0m"
|
|
196
|
+
puts " \e[33m#{label}\e[0m"
|
|
197
|
+
puts "#{"\e[33m=" * 96}\e[0m"
|
|
198
|
+
|
|
199
|
+
puts "TIME QUERIES EXAMPLES FOLDER"
|
|
200
|
+
|
|
201
|
+
folder_times.sort_by { |_, t| -t }.each do |folder, time|
|
|
202
|
+
puts format("%6.2fs %6d %4d %s", time, folder_query_counts[folder], folder_example_counts[folder], folder)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
puts
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec_turbo"
|
|
4
|
+
|
|
5
|
+
namespace :spec do
|
|
6
|
+
desc "Run the full RSpec suite in parallel with rspec-turbo"
|
|
7
|
+
task :turbo do
|
|
8
|
+
# Runs the whole suite; the runner spawns its own per-worker rspec
|
|
9
|
+
# processes (so this task needs no :environment) and exits with the
|
|
10
|
+
# suite's status. For specific folders or flags, use `rspec-turbo` directly.
|
|
11
|
+
RSpecTurbo::Runner.new([]).run
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
namespace :coverage do
|
|
16
|
+
# Merge the per-worker SimpleCov result files into one report. Workers run
|
|
17
|
+
# with their own TEST_ENV_NUMBER, so each writes a separate .resultset.json
|
|
18
|
+
# (point them at coverage/$TEST_ENV_NUMBER/ in spec_helper — see the README).
|
|
19
|
+
# Invoked automatically by the runner when COVERAGE=1.
|
|
20
|
+
desc "Merge per-worker SimpleCov results (JSON on CI, HTML locally)"
|
|
21
|
+
task :merge do
|
|
22
|
+
begin
|
|
23
|
+
require "simplecov"
|
|
24
|
+
require "simplecov_json_formatter"
|
|
25
|
+
rescue LoadError => e
|
|
26
|
+
abort "coverage:merge needs `simplecov` and `simplecov_json_formatter` in your Gemfile — #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
pattern = ENV.fetch("RSPEC_TURBO_COVERAGE_GLOB", "coverage/**/.resultset.json")
|
|
30
|
+
result_files = Dir[pattern]
|
|
31
|
+
|
|
32
|
+
if result_files.empty?
|
|
33
|
+
warn "coverage:merge: no result files matched #{pattern.inspect} — nothing to merge"
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
on_ci = %w[1 true].include?(ENV["CI"].to_s.downcase)
|
|
38
|
+
chosen_formatter = on_ci ? SimpleCov::Formatter::JSONFormatter : SimpleCov::Formatter::HTMLFormatter
|
|
39
|
+
|
|
40
|
+
puts "Merging #{result_files.size} coverage result file(s) → #{chosen_formatter}"
|
|
41
|
+
|
|
42
|
+
SimpleCov.collate(result_files) do
|
|
43
|
+
formatter chosen_formatter
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTurbo
|
|
4
|
+
# Pure presentation helpers shared across the reporting code: duration
|
|
5
|
+
# formatting, optional ANSI colour, spinner frames and rule separators.
|
|
6
|
+
#
|
|
7
|
+
# On CI (no TTY) colour is dropped and box-drawing characters fall back to
|
|
8
|
+
# plain ASCII, which CI log viewers render without mangling.
|
|
9
|
+
module Terminal
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
13
|
+
|
|
14
|
+
SEP_THIN = (Config::TTY ? "─" : "=") * 68
|
|
15
|
+
SEP_THICK = (Config::TTY ? "═" : "=") * 68
|
|
16
|
+
|
|
17
|
+
def fmt_duration(seconds)
|
|
18
|
+
minutes, secs = seconds.divmod(60)
|
|
19
|
+
|
|
20
|
+
minutes.positive? ? format("%dm%02ds", minutes, secs) : format("%ds", secs)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Wraps text in an ANSI escape sequence only when running in a TTY.
|
|
24
|
+
def c(code, text) = Config::TTY ? "\e[#{code}m#{text}\e[0m" : text
|
|
25
|
+
|
|
26
|
+
def strip_ansi(text) = text.gsub(/\e\[[0-9;]*m/, "")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RSpecTurbo
|
|
6
|
+
# A single RSpec process running one batch of work. Worker.spawn forks the
|
|
7
|
+
# process and returns a Worker the executor tracks until the process exits.
|
|
8
|
+
#
|
|
9
|
+
# Every worker is wired with two helper files shipped by the gem:
|
|
10
|
+
# * progress_reporter.rb — a formatter that streams the example count to a
|
|
11
|
+
# progress file so the parent can draw a global progress bar.
|
|
12
|
+
# * slow_profile.rb — an opt-in profiler (RSPEC_PROFILE_SLOW=1) that
|
|
13
|
+
# emits the "TOP N FILES BY TIME" block the report aggregates. It is a
|
|
14
|
+
# no-op when profiling is disabled, so requiring it is always safe.
|
|
15
|
+
class Worker
|
|
16
|
+
PROGRESS_REPORTER_PATH = File.expand_path("progress_reporter.rb", __dir__)
|
|
17
|
+
SLOW_PROFILE_PATH = File.expand_path("slow_profile.rb", __dir__)
|
|
18
|
+
|
|
19
|
+
attr_reader :pid, :label, :units, :slot, :started, :progress_file
|
|
20
|
+
|
|
21
|
+
def self.spawn(label:, units:, slot:, rspec_options:)
|
|
22
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
|
+
progress_file = Config.progress_path(slot)
|
|
24
|
+
File.write(progress_file, "0")
|
|
25
|
+
|
|
26
|
+
pid = Process.spawn(
|
|
27
|
+
env(slot, progress_file),
|
|
28
|
+
"bundle", "exec", "rspec", "--color", "--order", "random",
|
|
29
|
+
"--require", PROGRESS_REPORTER_PATH,
|
|
30
|
+
"--require", SLOW_PROFILE_PATH,
|
|
31
|
+
"--format", "progress",
|
|
32
|
+
"--format", "RSpecTurbo::ProgressReporter",
|
|
33
|
+
*junit_args(slot),
|
|
34
|
+
*rspec_options, *rspec_args(units),
|
|
35
|
+
out: Config.log_path(label), err: [:child, :out]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
new(pid: pid, label: label, units: units, slot: slot, started: started, progress_file: progress_file)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.env(slot, progress_file)
|
|
42
|
+
{
|
|
43
|
+
"TEST_ENV_NUMBER" => slot.to_s,
|
|
44
|
+
"RSPEC_TURBO_PROGRESS_FILE" => progress_file,
|
|
45
|
+
"COVERAGE" => ENV.fetch("COVERAGE", "0")
|
|
46
|
+
}.merge(profile_env)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Profiling is on by default: turbo flips RSPEC_PROFILE_SLOW=1 in the child
|
|
50
|
+
# unless the user set it themselves. RSPEC_TURBO_NO_PROFILE=1 hard-unsets the
|
|
51
|
+
# profiler envs in the child (nil = unset), winning over any inherited value.
|
|
52
|
+
def self.profile_env
|
|
53
|
+
return {"RSPEC_PROFILE_SLOW" => nil, "RSPEC_PROFILE_GROUP_BY" => nil} unless Config.profile?
|
|
54
|
+
|
|
55
|
+
{"RSPEC_PROFILE_SLOW" => ENV.fetch("RSPEC_PROFILE_SLOW", "1")}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A unit is either a file path (String) or a pre-resolved list of example
|
|
59
|
+
# IDs (Array) — the latter is already in `spec/...[1:2]` form.
|
|
60
|
+
def self.rspec_args(units)
|
|
61
|
+
units.flat_map { |unit| unit.is_a?(Array) ? unit : ["spec/#{unit}"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.junit_args(slot)
|
|
65
|
+
dir = Config.junit_dir
|
|
66
|
+
return [] unless dir
|
|
67
|
+
|
|
68
|
+
FileUtils.mkdir_p(dir)
|
|
69
|
+
|
|
70
|
+
["--require", "rspec_junit_formatter",
|
|
71
|
+
"--format", "RspecJunitFormatter",
|
|
72
|
+
"--out", File.join(dir, "rspec-turbo-#{slot}.xml")]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private_class_method :env, :profile_env, :rspec_args, :junit_args
|
|
76
|
+
|
|
77
|
+
def initialize(pid:, label:, units:, slot:, started:, progress_file:)
|
|
78
|
+
@pid = pid
|
|
79
|
+
@label = label
|
|
80
|
+
@units = units
|
|
81
|
+
@slot = slot
|
|
82
|
+
@started = started
|
|
83
|
+
@progress_file = progress_file
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started).round
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/rspec_turbo.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rspec_turbo/version"
|
|
4
|
+
require_relative "rspec_turbo/config"
|
|
5
|
+
require_relative "rspec_turbo/terminal"
|
|
6
|
+
require_relative "rspec_turbo/options"
|
|
7
|
+
require_relative "rspec_turbo/db_setup"
|
|
8
|
+
require_relative "rspec_turbo/file_discovery"
|
|
9
|
+
require_relative "rspec_turbo/batch_planner"
|
|
10
|
+
require_relative "rspec_turbo/display"
|
|
11
|
+
require_relative "rspec_turbo/worker"
|
|
12
|
+
require_relative "rspec_turbo/executor"
|
|
13
|
+
require_relative "rspec_turbo/runner"
|
|
14
|
+
|
|
15
|
+
# Parallel RSpec runner with smart, dry-run-based example balancing.
|
|
16
|
+
#
|
|
17
|
+
# progress_reporter.rb and slow_profile.rb are intentionally NOT required here:
|
|
18
|
+
# they run inside the spawned worker processes (loaded by absolute path), not in
|
|
19
|
+
# the orchestrator, so the parent never has to load rspec-core or ActiveSupport.
|
|
20
|
+
module RSpecTurbo
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# In a Rails app, register the `spec:turbo` Rake task (also reachable as
|
|
24
|
+
# `rails spec:turbo`). Skipped entirely when Rails isn't loaded.
|
|
25
|
+
require_relative "rspec_turbo/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rspec-turbo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- thadeu
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-12 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: simplecov
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.22'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.22'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: simplecov_json_formatter
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.1'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.1'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: standard
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.50'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.50'
|
|
96
|
+
description: |
|
|
97
|
+
rspec-turbo runs your RSpec suite across N processes like parallel_tests,
|
|
98
|
+
but balances work by actual example count (from a single --dry-run) using an
|
|
99
|
+
LPT bin-packing heuristic, splitting oversized files across workers. It ships
|
|
100
|
+
a live TTY dashboard, a CI-friendly progress mode, schema-fingerprinted test
|
|
101
|
+
DB setup caching, JUnit output, coverage merging, and a slowest folders/files
|
|
102
|
+
report.
|
|
103
|
+
email:
|
|
104
|
+
- tadeuu@gmail.com
|
|
105
|
+
executables:
|
|
106
|
+
- rspec-turbo
|
|
107
|
+
extensions: []
|
|
108
|
+
extra_rdoc_files: []
|
|
109
|
+
files:
|
|
110
|
+
- CHANGELOG.md
|
|
111
|
+
- LICENSE.txt
|
|
112
|
+
- README.md
|
|
113
|
+
- exe/rspec-turbo
|
|
114
|
+
- lib/rspec-turbo.rb
|
|
115
|
+
- lib/rspec_turbo.rb
|
|
116
|
+
- lib/rspec_turbo/batch_planner.rb
|
|
117
|
+
- lib/rspec_turbo/config.rb
|
|
118
|
+
- lib/rspec_turbo/db_setup.rb
|
|
119
|
+
- lib/rspec_turbo/display.rb
|
|
120
|
+
- lib/rspec_turbo/executor.rb
|
|
121
|
+
- lib/rspec_turbo/file_discovery.rb
|
|
122
|
+
- lib/rspec_turbo/options.rb
|
|
123
|
+
- lib/rspec_turbo/progress_reporter.rb
|
|
124
|
+
- lib/rspec_turbo/railtie.rb
|
|
125
|
+
- lib/rspec_turbo/runner.rb
|
|
126
|
+
- lib/rspec_turbo/slow_profile.rb
|
|
127
|
+
- lib/rspec_turbo/tasks.rake
|
|
128
|
+
- lib/rspec_turbo/terminal.rb
|
|
129
|
+
- lib/rspec_turbo/version.rb
|
|
130
|
+
- lib/rspec_turbo/worker.rb
|
|
131
|
+
homepage: https://github.com/thadeu/rspec-turbo
|
|
132
|
+
licenses:
|
|
133
|
+
- MIT
|
|
134
|
+
metadata:
|
|
135
|
+
source_code_uri: https://github.com/thadeu/rspec-turbo
|
|
136
|
+
changelog_uri: https://github.com/thadeu/rspec-turbo/blob/main/CHANGELOG.md
|
|
137
|
+
rubygems_mfa_required: 'true'
|
|
138
|
+
rdoc_options: []
|
|
139
|
+
require_paths:
|
|
140
|
+
- lib
|
|
141
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '3.0'
|
|
146
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
147
|
+
requirements:
|
|
148
|
+
- - ">="
|
|
149
|
+
- !ruby/object:Gem::Version
|
|
150
|
+
version: '0'
|
|
151
|
+
requirements: []
|
|
152
|
+
rubygems_version: 3.6.2
|
|
153
|
+
specification_version: 4
|
|
154
|
+
summary: Parallel RSpec runner with smart, dry-run-based example balancing.
|
|
155
|
+
test_files: []
|