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,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTurbo
|
|
4
|
+
# Owns every line printed to the terminal: the live per-worker spinner
|
|
5
|
+
# (TTY), the plain CI worker roster, and the final report — failures plus
|
|
6
|
+
# the slowest folders/files (fed by slow_profile.rb in each worker log).
|
|
7
|
+
class Display
|
|
8
|
+
def initialize(planner)
|
|
9
|
+
@planner = planner
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Folder label string for a batch's units, e.g. "models · requests".
|
|
13
|
+
def self.folder_labels(batch_units, with_counts: false, max_len: nil)
|
|
14
|
+
counts = Hash.new(0)
|
|
15
|
+
batch_units.each { |unit| counts[folder_for(unit)] += 1 }
|
|
16
|
+
label = counts.map { |folder, n| with_counts ? "#{folder}(#{n})" : folder }.join(" · ")
|
|
17
|
+
|
|
18
|
+
return label unless max_len
|
|
19
|
+
|
|
20
|
+
label.slice(0, max_len).then { |slice| (slice.length < label.length) ? "#{slice}…" : slice }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.folder_for(unit)
|
|
24
|
+
path = unit.is_a?(Array) ? unit.first.split("[").first.delete_prefix("./spec/") : unit
|
|
25
|
+
parts = path.split("/")
|
|
26
|
+
|
|
27
|
+
return parts[0] if parts.size <= 1 || parts[1].end_with?("_spec.rb")
|
|
28
|
+
|
|
29
|
+
"#{parts[0]}/#{parts[1]}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def print_plan
|
|
33
|
+
total = @planner.counts.values.sum
|
|
34
|
+
|
|
35
|
+
puts
|
|
36
|
+
puts " Found #{@planner.batches.flatten.size} spec files (#{total} examples) → #{@planner.batches.size} batches"
|
|
37
|
+
puts
|
|
38
|
+
|
|
39
|
+
@planner.batches.each_with_index do |batch, i|
|
|
40
|
+
label = format("worker/%02d", i + 1)
|
|
41
|
+
folders = Display.folder_labels(batch)
|
|
42
|
+
|
|
43
|
+
puts format(" %-10s %3d files ~%-4d ex %s", label, batch.size, @planner.example_count(batch), folders)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def print_report(results, wall_total, n_workers)
|
|
50
|
+
sum_total = results.sum { |r| r[:duration] }
|
|
51
|
+
speedup = sum_total.positive? ? (sum_total.to_f / wall_total).round(2) : 0
|
|
52
|
+
total_ex = results.sum { |r| @planner.example_count(r[:units]) }
|
|
53
|
+
file_data = parse_profiler_data(results.map { |r| Config.log_path(r[:label]) })
|
|
54
|
+
failed = results.select { |r| r[:status] == "FAIL" }
|
|
55
|
+
|
|
56
|
+
puts
|
|
57
|
+
puts Terminal::SEP_THICK
|
|
58
|
+
puts " RSpec Turbo Report"
|
|
59
|
+
puts Terminal::SEP_THICK
|
|
60
|
+
|
|
61
|
+
# On a TTY you watch failures scroll by live, so show them first. In CI
|
|
62
|
+
# the log is read top-to-bottom afterwards, so push failures to the very
|
|
63
|
+
# end where they sit right above the one-line summary — easy to find.
|
|
64
|
+
if Config::TTY
|
|
65
|
+
print_failures(failed)
|
|
66
|
+
print_slowest(file_data)
|
|
67
|
+
else
|
|
68
|
+
print_slowest(file_data)
|
|
69
|
+
print_failures(failed)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
print_summary(failed, total_ex, wall_total, sum_total, speedup, n_workers)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Consolidated, sorted PASS/FAIL roster — printed once after every worker
|
|
76
|
+
# finishes (CI path) so the per-worker results form one clean block.
|
|
77
|
+
def print_worker_summary(results)
|
|
78
|
+
return if results.empty?
|
|
79
|
+
|
|
80
|
+
puts
|
|
81
|
+
puts Terminal::SEP_THICK
|
|
82
|
+
puts " Workers"
|
|
83
|
+
puts Terminal::SEP_THICK
|
|
84
|
+
puts
|
|
85
|
+
|
|
86
|
+
results.sort_by { |r| r[:label] }.each do |r|
|
|
87
|
+
icon = (r[:status] == "PASS") ? "✓" : "✗"
|
|
88
|
+
code = (r[:status] == "PASS") ? "32" : "31"
|
|
89
|
+
line = format("#{icon} %-10s %-7s %-6s %s",
|
|
90
|
+
r[:label], Terminal.fmt_duration(r[:duration]), r[:status], Display.folder_labels(r[:units]))
|
|
91
|
+
|
|
92
|
+
puts Terminal.c(code, line)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def spinner_line(state, frame)
|
|
97
|
+
folders = Display.folder_labels(state[:units], max_len: 55)
|
|
98
|
+
|
|
99
|
+
case state[:status]
|
|
100
|
+
when :pending
|
|
101
|
+
" \e[90m○ #{state[:label]}\e[0m"
|
|
102
|
+
when :running
|
|
103
|
+
elapsed = state[:started] ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) - state[:started]).round : 0
|
|
104
|
+
spin = Terminal::SPINNER_FRAMES[frame % Terminal::SPINNER_FRAMES.size]
|
|
105
|
+
total = @planner.example_count(state[:units])
|
|
106
|
+
|
|
107
|
+
" \e[36m#{spin} #{state[:label]}\e[0m ~#{total} ex #{Terminal.fmt_duration(elapsed)} #{folders}"
|
|
108
|
+
when :done
|
|
109
|
+
color = (state[:result] == "PASS") ? "\e[32m" : "\e[31m"
|
|
110
|
+
icon = (state[:result] == "PASS") ? "✓" : "✗"
|
|
111
|
+
|
|
112
|
+
" #{color}#{icon} #{state[:label]} #{Terminal.fmt_duration(state[:duration])} #{state[:result]} #{folders}\e[0m"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def print_summary(failed, total_ex, wall_total, sum_total, speedup, n_workers)
|
|
119
|
+
pending_count = @planner.pending_count
|
|
120
|
+
pass_fail = failed.empty? ? Terminal.c("32", "✓ All passed") : Terminal.c("31", "✗ #{failed.size} failed")
|
|
121
|
+
wall_str = Terminal.c("33", format("wall %-7s", Terminal.fmt_duration(wall_total)))
|
|
122
|
+
sum_str = Terminal.c("90", "sum #{Terminal.fmt_duration(sum_total)}")
|
|
123
|
+
spd_str = Terminal.c("1", "#{speedup}x")
|
|
124
|
+
pending_str = pending_count.positive? ? " · #{Terminal.c("33", "#{pending_count} pending")}" : ""
|
|
125
|
+
|
|
126
|
+
puts
|
|
127
|
+
puts " #{pass_fail} · #{total_ex} examples#{pending_str} · #{n_workers} workers · #{wall_str} #{sum_str} #{spd_str}"
|
|
128
|
+
puts
|
|
129
|
+
puts Terminal::SEP_THICK
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_failures(failed)
|
|
133
|
+
return if failed.empty?
|
|
134
|
+
|
|
135
|
+
puts " #{Terminal.c("31", "✗ #{failed.size} worker(s) failed: #{failed.map { |r| r[:label] }.join(", ")}")}"
|
|
136
|
+
|
|
137
|
+
failed.each do |r|
|
|
138
|
+
content = clean_log(Config.log_path(r[:label]))
|
|
139
|
+
next unless content
|
|
140
|
+
|
|
141
|
+
puts
|
|
142
|
+
puts Terminal.c("31", Terminal::SEP_THIN)
|
|
143
|
+
puts Terminal.c("31", " Failures in #{r[:label]}")
|
|
144
|
+
puts Terminal.c("31", Terminal::SEP_THIN)
|
|
145
|
+
puts extract_failures(content)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
puts " #{Terminal::SEP_THIN}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_failures(content)
|
|
152
|
+
start = content.index("\nFailures:\n") ||
|
|
153
|
+
content.index("\nAn error occurred while loading") ||
|
|
154
|
+
content.index("\nFailed examples:")
|
|
155
|
+
|
|
156
|
+
return content.lines.last(30).join.strip unless start
|
|
157
|
+
|
|
158
|
+
finish = content.index("\nFinished in") ||
|
|
159
|
+
content.index(/\nTop \d/) ||
|
|
160
|
+
content.length
|
|
161
|
+
|
|
162
|
+
content[start...finish].strip
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def print_slowest(file_data)
|
|
166
|
+
return if file_data.empty?
|
|
167
|
+
|
|
168
|
+
puts " #{Terminal.c("1", "Slowest folders")} #{Terminal.c("90", "↳ optimize these first")}"
|
|
169
|
+
puts
|
|
170
|
+
|
|
171
|
+
folder_times = aggregate_by_folder(file_data)
|
|
172
|
+
max_s = folder_times.first&.last.to_f
|
|
173
|
+
|
|
174
|
+
folder_times.first(8).each do |folder, seconds|
|
|
175
|
+
bar_width = max_s.positive? ? [(seconds / max_s * 20).round, 1].max : 1
|
|
176
|
+
|
|
177
|
+
puts format(" %-45s %6s %s", folder, Terminal.fmt_duration(seconds.round), slowest_bar(bar_width))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
puts " #{Terminal::SEP_THIN}"
|
|
181
|
+
puts " #{Terminal.c("1", "Slowest files")}"
|
|
182
|
+
puts
|
|
183
|
+
|
|
184
|
+
file_data.sort_by { |e| -e[:seconds] }.first(5).each do |e|
|
|
185
|
+
puts format(" %-60s %s", e[:file].delete_prefix("spec/"), Terminal.fmt_duration(e[:seconds].round))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
puts " #{Terminal::SEP_THIN}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def slowest_bar(width)
|
|
192
|
+
return "#{"#" * width}#{"." * (20 - width)}" unless Config::TTY
|
|
193
|
+
|
|
194
|
+
"\e[37m#{"▓" * width}\e[90m#{"░" * (20 - width)}\e[0m"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Parse "TOP N FILES BY TIME" sections (emitted by slow_profile.rb) from
|
|
198
|
+
# every worker log and flatten them into [{seconds:, file:}].
|
|
199
|
+
def parse_profiler_data(log_files)
|
|
200
|
+
log_files.filter_map { |log| clean_log(log) }.flat_map do |content|
|
|
201
|
+
section = content[/TOP \d+ FILES BY TIME.*?(?=\n\n|\z)/m]
|
|
202
|
+
next [] unless section
|
|
203
|
+
|
|
204
|
+
section.each_line.filter_map do |line|
|
|
205
|
+
m = line.match(%r{^\s*([\d.]+)s\s+\d+\s+(spec/.+)$})
|
|
206
|
+
m && {seconds: m[1].to_f, file: m[2].strip}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Aggregate file times by their immediate parent folder (relative to spec/).
|
|
212
|
+
def aggregate_by_folder(file_data)
|
|
213
|
+
sums = Hash.new(0.0)
|
|
214
|
+
|
|
215
|
+
file_data.each do |e|
|
|
216
|
+
parts = e[:file].delete_prefix("spec/").split("/")
|
|
217
|
+
folder = (parts.size > 1) ? parts[0..-2].join("/") : parts[0]
|
|
218
|
+
sums[folder] += e[:seconds]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
sums.sort_by { |_, seconds| -seconds }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Read a worker log, scrub invalid bytes and strip ANSI colour codes.
|
|
225
|
+
def clean_log(path)
|
|
226
|
+
return nil unless File.exist?(path)
|
|
227
|
+
|
|
228
|
+
Terminal.strip_ansi(File.binread(path).force_encoding("UTF-8").scrub)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTurbo
|
|
4
|
+
# Runs the planned batches across a fixed pool of slots: one Worker per free
|
|
5
|
+
# slot at a time, slots recycled as workers finish until the queue drains.
|
|
6
|
+
#
|
|
7
|
+
# Renders a live multi-line dashboard on a TTY (per-worker spinner lines plus
|
|
8
|
+
# a global progress bar) and periodic single-line [progress] updates on CI.
|
|
9
|
+
class Executor
|
|
10
|
+
attr_reader :wall_started
|
|
11
|
+
|
|
12
|
+
def initialize(planner, display, rspec_options)
|
|
13
|
+
@planner = planner
|
|
14
|
+
@display = display
|
|
15
|
+
@rspec_options = rspec_options
|
|
16
|
+
@labels = planner.batches.each_index.map { |i| format("worker/%02d", i + 1) }
|
|
17
|
+
@slots = (1..planner.batches.size).to_a
|
|
18
|
+
@total_examples = planner.counts.values.sum
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
pending = Queue.new
|
|
23
|
+
@planner.batches.each_with_index { |units, i| pending << [@labels[i], units] }
|
|
24
|
+
|
|
25
|
+
results = []
|
|
26
|
+
@wall_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
27
|
+
|
|
28
|
+
if Config::TTY
|
|
29
|
+
run_tty(pending, results)
|
|
30
|
+
else
|
|
31
|
+
run_plain(pending, results)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
results
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def run_tty(pending, results)
|
|
40
|
+
n = @labels.size
|
|
41
|
+
live = live_state
|
|
42
|
+
mutex = Mutex.new
|
|
43
|
+
frame = 0
|
|
44
|
+
spinning = true
|
|
45
|
+
max_done = 0
|
|
46
|
+
|
|
47
|
+
(n + 2).times { puts } # reserve N worker lines + blank + progress bar
|
|
48
|
+
|
|
49
|
+
print "\e[?25l"
|
|
50
|
+
Signal.trap("INT") {
|
|
51
|
+
print "\e[?25h"
|
|
52
|
+
exit 130
|
|
53
|
+
}
|
|
54
|
+
Signal.trap("TERM") {
|
|
55
|
+
print "\e[?25h"
|
|
56
|
+
exit 143
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
spinner = Thread.new do
|
|
60
|
+
while spinning
|
|
61
|
+
sleep 0.1
|
|
62
|
+
mutex.synchronize do
|
|
63
|
+
done = @slots.sum { |s| read_progress(Config.progress_path(s)) }
|
|
64
|
+
max_done = done if done > max_done
|
|
65
|
+
repaint(live, frame, n, max_done)
|
|
66
|
+
frame += 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
run_pool(pending, results) do |event, worker, result|
|
|
72
|
+
mutex.synchronize do
|
|
73
|
+
case event
|
|
74
|
+
when :started then live[worker.label].merge!(status: :running, started: worker.started)
|
|
75
|
+
when :done then live[worker.label].merge!(status: :done, duration: result[:duration], result: result[:status])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
spinning = false
|
|
81
|
+
spinner.join
|
|
82
|
+
mutex.synchronize { repaint(live, frame, n, @total_examples) }
|
|
83
|
+
cleanup_progress
|
|
84
|
+
print "\e[?25h"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_plain(pending, results)
|
|
88
|
+
total_workers = @planner.batches.size
|
|
89
|
+
active = true
|
|
90
|
+
interval = Config.progress_interval
|
|
91
|
+
completed = 0
|
|
92
|
+
|
|
93
|
+
puts Terminal::SEP_THICK
|
|
94
|
+
puts " RSpec Turbo - CI Progress"
|
|
95
|
+
puts Terminal::SEP_THICK
|
|
96
|
+
puts
|
|
97
|
+
puts " [progress] 0s 0/#{@total_examples} examples 0% (0/#{total_workers} workers done)"
|
|
98
|
+
$stdout.flush
|
|
99
|
+
|
|
100
|
+
ticker = Thread.new do
|
|
101
|
+
ticks = 0
|
|
102
|
+
|
|
103
|
+
while active
|
|
104
|
+
sleep 1
|
|
105
|
+
ticks += 1
|
|
106
|
+
next unless (ticks % interval).zero?
|
|
107
|
+
|
|
108
|
+
done = @slots.sum { |s| read_progress(Config.progress_path(s)) }
|
|
109
|
+
pct = @total_examples.positive? ? "#{(done.to_f / @total_examples * 100).round}%" : "?"
|
|
110
|
+
wall = Terminal.fmt_duration(elapsed_since(@wall_started))
|
|
111
|
+
puts " [progress] #{wall} #{done}/#{@total_examples} examples #{pct} (#{completed}/#{total_workers} workers done)"
|
|
112
|
+
$stdout.flush
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
run_pool(pending, results) { |event, _worker, _result| completed += 1 if event == :done }
|
|
117
|
+
ensure
|
|
118
|
+
active = false
|
|
119
|
+
ticker&.join
|
|
120
|
+
@display.print_worker_summary(results)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Shared scheduling loop: keep every free slot busy, reap finished workers,
|
|
124
|
+
# recycle their slot, record the result, and yield lifecycle events.
|
|
125
|
+
def run_pool(pending, results)
|
|
126
|
+
in_flight = {}
|
|
127
|
+
free_slots = @slots.dup
|
|
128
|
+
|
|
129
|
+
loop do
|
|
130
|
+
while !free_slots.empty? && !pending.empty?
|
|
131
|
+
label, units = pending.pop
|
|
132
|
+
slot = free_slots.shift
|
|
133
|
+
worker = Worker.spawn(label: label, units: units, slot: slot, rspec_options: @rspec_options)
|
|
134
|
+
in_flight[worker.pid] = worker
|
|
135
|
+
yield(:started, worker)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
break if in_flight.empty?
|
|
139
|
+
|
|
140
|
+
pid, status = Process.wait2
|
|
141
|
+
next unless (worker = in_flight.delete(pid))
|
|
142
|
+
|
|
143
|
+
free_slots.push(worker.slot)
|
|
144
|
+
result = {label: worker.label, units: worker.units, status: status.success? ? "PASS" : "FAIL", duration: worker.duration}
|
|
145
|
+
results << result
|
|
146
|
+
yield(:done, worker, result)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def live_state
|
|
151
|
+
@labels.each_with_index.to_h do |label, i|
|
|
152
|
+
[label, {label: label, status: :pending, units: @planner.batches[i], started: nil, duration: nil, result: nil}]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def repaint(live, frame, n, done)
|
|
157
|
+
print "\e[#{n + 2}A"
|
|
158
|
+
@labels.each { |label| print "\e[2K#{@display.spinner_line(live[label], frame)}\n" }
|
|
159
|
+
print "\e[2K\n"
|
|
160
|
+
print "\e[2K#{progress_bar(done, @total_examples)}\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def cleanup_progress
|
|
164
|
+
@slots.each do |slot|
|
|
165
|
+
File.delete(Config.progress_path(slot))
|
|
166
|
+
rescue
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def elapsed_since(start_time) = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
172
|
+
|
|
173
|
+
def read_progress(file)
|
|
174
|
+
return 0 unless file && File.exist?(file)
|
|
175
|
+
|
|
176
|
+
Integer(File.read(file).strip)
|
|
177
|
+
rescue
|
|
178
|
+
0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def progress_bar(done, total, width: 36)
|
|
182
|
+
return "" if total.zero?
|
|
183
|
+
|
|
184
|
+
pct = (done.to_f / total * 100).round
|
|
185
|
+
filled = (done.to_f / total * width).round
|
|
186
|
+
bar = "\e[37m#{"▓" * filled}\e[90m#{"░" * (width - filled)}\e[0m"
|
|
187
|
+
|
|
188
|
+
" #{bar} \e[36m#{done}/#{total}\e[0m \e[1m#{pct}%\e[0m"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTurbo
|
|
4
|
+
# Discovers *_spec.rb files under the given folders (or all of spec/ if none
|
|
5
|
+
# are given), de-duplicates them and applies --exclude-pattern glob filters.
|
|
6
|
+
#
|
|
7
|
+
# Returned paths are relative to spec/ (e.g. "models/user_spec.rb").
|
|
8
|
+
class FileDiscovery
|
|
9
|
+
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
10
|
+
|
|
11
|
+
def initialize(folders, exclude_patterns: [])
|
|
12
|
+
@folders = folders
|
|
13
|
+
@exclude_patterns = exclude_patterns
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def files
|
|
17
|
+
raw = collect_files
|
|
18
|
+
|
|
19
|
+
return raw if @exclude_patterns.empty?
|
|
20
|
+
|
|
21
|
+
raw.reject do |file|
|
|
22
|
+
@exclude_patterns.any? { |pattern| File.fnmatch(pattern, "spec/#{file}", FNMATCH_FLAGS) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def collect_files
|
|
29
|
+
seen = Set.new
|
|
30
|
+
found = []
|
|
31
|
+
bases = @folders.empty? ? [""] : @folders
|
|
32
|
+
|
|
33
|
+
bases.each do |folder|
|
|
34
|
+
folder = folder.delete_prefix("spec/")
|
|
35
|
+
folder = "" if folder == "spec" # bare "spec" means the whole spec/ tree
|
|
36
|
+
base = folder.empty? ? "spec" : File.join("spec", folder)
|
|
37
|
+
|
|
38
|
+
if File.file?(base)
|
|
39
|
+
found << folder if seen.add?(folder)
|
|
40
|
+
elsif File.directory?(base)
|
|
41
|
+
Dir.glob("#{base}/**/*_spec.rb").sort.each do |path|
|
|
42
|
+
rel = path.delete_prefix("spec/")
|
|
43
|
+
found << rel if seen.add?(rel)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
warn "▶ Skipping #{base} (not found)"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
found
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTurbo
|
|
4
|
+
# Splits the raw ARGV into RSpec options and target folders/files, and pulls
|
|
5
|
+
# out --exclude-pattern values for the file discovery step.
|
|
6
|
+
#
|
|
7
|
+
# The tricky part is knowing which flags consume the next token as a value
|
|
8
|
+
# (so it is not mistaken for a folder); OPTIONS_WITH_VALUES lists those.
|
|
9
|
+
class Options
|
|
10
|
+
OPTIONS_WITH_VALUES = Set.new(%w[
|
|
11
|
+
--exclude-pattern --pattern --format --require --order --seed
|
|
12
|
+
--tag --failure-exit-code --backtrace-exclusion-pattern
|
|
13
|
+
]).freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :rspec_options, :folders, :exclude_patterns
|
|
16
|
+
|
|
17
|
+
def initialize(argv)
|
|
18
|
+
@rspec_options, @folders = parse(argv)
|
|
19
|
+
@exclude_patterns = extract_exclude_patterns(@rspec_options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def parse(argv)
|
|
25
|
+
options = []
|
|
26
|
+
folders = []
|
|
27
|
+
i = 0
|
|
28
|
+
|
|
29
|
+
while i < argv.length
|
|
30
|
+
arg = argv[i]
|
|
31
|
+
|
|
32
|
+
unless arg.start_with?("-")
|
|
33
|
+
folders << arg
|
|
34
|
+
i += 1
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
options << arg
|
|
39
|
+
|
|
40
|
+
if takes_value?(arg, argv[i + 1])
|
|
41
|
+
options << argv[i + 1]
|
|
42
|
+
i += 2
|
|
43
|
+
else
|
|
44
|
+
i += 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
[options, folders]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def takes_value?(arg, next_arg)
|
|
52
|
+
!arg.include?("=") &&
|
|
53
|
+
OPTIONS_WITH_VALUES.include?(arg.split("=").first) &&
|
|
54
|
+
next_arg && !next_arg.start_with?("-")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_exclude_patterns(options)
|
|
58
|
+
patterns = []
|
|
59
|
+
|
|
60
|
+
options.each_with_index do |opt, idx|
|
|
61
|
+
if opt.start_with?("--exclude-pattern=")
|
|
62
|
+
patterns << opt.delete_prefix("--exclude-pattern=")
|
|
63
|
+
elsif opt == "--exclude-pattern"
|
|
64
|
+
patterns << options[idx + 1] if options[idx + 1]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
patterns
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/core/formatters/base_formatter"
|
|
5
|
+
|
|
6
|
+
module RSpecTurbo
|
|
7
|
+
# RSpec formatter loaded inside each worker process. Its only job is to write
|
|
8
|
+
# the running example count to RSPEC_TURBO_PROGRESS_FILE after every example,
|
|
9
|
+
# so the parent runner can sum the slots and draw a live progress bar.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately self-contained (no dependency on the rest of the gem) so it can
|
|
12
|
+
# be required by absolute path inside the spawned `rspec` process. The slowest
|
|
13
|
+
# files report is produced separately by slow_profile.rb.
|
|
14
|
+
class ProgressReporter < RSpec::Core::Formatters::BaseFormatter
|
|
15
|
+
RSpec::Core::Formatters.register self, :example_finished
|
|
16
|
+
|
|
17
|
+
def initialize(output)
|
|
18
|
+
super
|
|
19
|
+
@count = 0
|
|
20
|
+
@progress_file = ENV["RSPEC_TURBO_PROGRESS_FILE"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def example_finished(_notification)
|
|
24
|
+
@count += 1
|
|
25
|
+
return unless @progress_file
|
|
26
|
+
|
|
27
|
+
File.write(@progress_file, @count.to_s)
|
|
28
|
+
rescue
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module RSpecTurbo
|
|
6
|
+
# Registers the `spec:turbo` Rake task in host Rails apps, so the suite can be
|
|
7
|
+
# launched via `rake spec:turbo` or `rails spec:turbo` (Rails routes unknown
|
|
8
|
+
# commands to Rake) in addition to the `rspec-turbo` binary.
|
|
9
|
+
#
|
|
10
|
+
# Only loaded when Rails is present (see the guard in lib/rspec_turbo.rb).
|
|
11
|
+
class Railtie < Rails::Railtie
|
|
12
|
+
rake_tasks do
|
|
13
|
+
load File.expand_path("tasks.rake", __dir__)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|