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.
@@ -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