parallel_specs 0.9.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 31a954499f658d2d18df5d6126b522cf299985b33f68e5016d8a19de4e1369bd
4
+ data.tar.gz: 80789703549b936ae3b97309b0979a72d6799f10cd3ab604f11b7f3ed316509c
5
+ SHA512:
6
+ metadata.gz: f994089098548075cccb2591e4e56dee64aee54fd861b92a30573a62e1358323b1bbf300af645f8aab23e6a5334c0f0b27cd834f61ffc118575f8c3e4737c83f
7
+ data.tar.gz: b6cf4496f6703d1d11d9375408bf718b7adf5c74327c8a9f12212b2956e17aabbb234f752092dbe44f4a97f5f9510351b32b5733f4f4646d8cd80ca6388bdbcb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scott Watermasysk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # Parallel Specs
2
+
3
+ ![Parallel Specs social preview](assets/github-social-preview-rspecish.png)
4
+
5
+ A focused extraction of the RSpec pieces from `parallel_tests` with only the parts this gem actually needs:
6
+
7
+ - a live local dashboard
8
+ - a plain-text CI / LLM friendly summary
9
+ - runtime-log generation for balanced spec splitting
10
+ - modern Ruby only
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ bundle exec parallel_specs
16
+ bundle exec parallel_specs -n 6
17
+ bundle exec parallel_specs --test-options='--tag ~type:system'
18
+ bundle exec parallel_specs --record-runtime
19
+ ```
20
+
21
+ Local TTY runs render the interactive dashboard. CI and other non-TTY runs automatically fall back to the plain text summary.
22
+
23
+ ## Runtime balancing
24
+
25
+ Regular runs automatically use `tmp/parallel_runtime_rspec.log` when it contains enough data.
26
+
27
+ Generate or refresh that file with:
28
+
29
+ ```bash
30
+ bundle exec parallel_specs --record-runtime
31
+ ```
32
+
33
+ Runtime logs are replaced only after a successful, complete run where every worker produces its runtime log. Failed, interrupted, incomplete, or no-spec runs preserve the previous runtime log.
34
+
35
+ Or point at a custom file:
36
+
37
+ ```bash
38
+ bundle exec parallel_specs --record-runtime --runtime-log tmp/my_runtime.log
39
+ ```
40
+
41
+ `--runtime-log PATH` is used as the input path for balancing and, with `--record-runtime`, as the output destination for the completed run.
42
+
43
+ ## Environment variables
44
+
45
+ The supported environment variables are:
46
+
47
+ - `PARALLEL_SPECS_PROCESSORS` sets the default worker count when `-n` is not provided.
48
+ - `PARALLEL_SPECS_DASHBOARD_MODE` can be `interactive` or `plain` to override automatic dashboard selection.
49
+ - `PARALLEL_SPECS_HEARTBEAT_INTERVAL` sets the plain-dashboard heartbeat interval in seconds.
50
+ - `CI` makes dashboard output default to plain mode.
51
+
52
+ Worker processes continue to receive `TEST_ENV_NUMBER` for compatibility with existing test-environment isolation setup.
53
+
54
+ This gem does not intentionally preserve the old `parallel_tests` executable names, formatter paths, or environment variable aliases.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ root = File.expand_path('..', __dir__)
5
+ $LOAD_PATH.unshift("#{root}/lib") if File.exist?("#{root}/parallel_specs.gemspec")
6
+
7
+ require 'parallel_specs'
8
+
9
+ ParallelSpecs::CLI.new.run(ARGV)
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'io/console'
5
+ require 'uri'
6
+
7
+ module ParallelSpecs
8
+ class CLI
9
+ class Dashboard
10
+ WorkerState = Struct.new(
11
+ :label,
12
+ :files_count,
13
+ :example_total,
14
+ :passed,
15
+ :failed,
16
+ :pending,
17
+ :current_example,
18
+ :started_at,
19
+ :finished_at,
20
+ :exit_status
21
+ )
22
+
23
+ SPINNER = ['-', '\\', '|', '/'].freeze
24
+ PROGRESS_BAR_WIDTH = 24
25
+ REFRESH_INTERVAL = 0.1
26
+
27
+ attr_reader :workers
28
+
29
+ def plain?
30
+ @mode == :plain
31
+ end
32
+
33
+ def initialize(groups:, event_files:, output: $stdout, use_colors: true, mode: :interactive, now: -> { ParallelSpecs.now }, width: nil, refresh_interval: REFRESH_INTERVAL)
34
+ @workers = groups.each_with_index.map do |group, index|
35
+ WorkerState.new(index + 1, group.size, nil, 0, 0, 0, nil, nil, nil, nil)
36
+ end
37
+ @event_files = event_files
38
+ @output = output
39
+ @use_colors = use_colors
40
+ @mode = mode
41
+ @now = now
42
+ @width = width
43
+ @refresh_interval = refresh_interval
44
+ @mutex = Mutex.new
45
+ @event_offsets = Hash.new(0)
46
+ @event_remainders = Hash.new { |hash, key| hash[key] = +"" }
47
+ @spinner_index = 0
48
+ @rendered_lines = 0
49
+ @dirty = true
50
+ end
51
+
52
+ def start
53
+ @mutex.synchronize do
54
+ @started_at = @now.call
55
+ render if interactive?
56
+ end
57
+
58
+ return unless interactive?
59
+
60
+ @running = true
61
+ @refresh_thread = Thread.new do
62
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
63
+
64
+ begin
65
+ while @running
66
+ sleep @refresh_interval
67
+ @mutex.synchronize do
68
+ poll_once
69
+ @spinner_index += 1
70
+ render if @dirty
71
+ end
72
+ end
73
+ rescue StandardError => e
74
+ @running = false
75
+ warn "parallel_specs: dashboard refresh failed while polling #{event_file_context}: #{e.class}: #{e.message}"
76
+ end
77
+ end
78
+ end
79
+
80
+ def stop
81
+ @running = false
82
+ @refresh_thread&.join
83
+
84
+ @mutex.synchronize do
85
+ begin
86
+ poll_once
87
+ rescue StandardError => e
88
+ warn "parallel_specs: dashboard final poll failed while polling #{event_file_context}: #{e.class}: #{e.message}"
89
+ end
90
+ render
91
+ @output.puts if interactive?
92
+ @output.flush if @output.respond_to?(:flush)
93
+ end
94
+ end
95
+
96
+ def worker_started(process_number)
97
+ synchronize do
98
+ worker = @workers.fetch(process_number)
99
+ worker.started_at ||= @now.call
100
+ @dirty = true
101
+ end
102
+ end
103
+
104
+ def worker_finished(process_number, exit_status:)
105
+ synchronize do
106
+ worker = @workers.fetch(process_number)
107
+ worker.started_at ||= @now.call
108
+ worker.finished_at = @now.call
109
+ worker.exit_status = exit_status
110
+ @dirty = true
111
+ end
112
+ end
113
+
114
+ def process_event(process_number, event)
115
+ worker = @workers.fetch(process_number)
116
+ worker.started_at ||= @now.call
117
+
118
+ case event.fetch('event')
119
+ when 'start'
120
+ worker.example_total = event['total']
121
+ when 'example_started'
122
+ worker.current_example = event['example']
123
+ when 'example_passed'
124
+ worker.passed += 1
125
+ worker.current_example = event['example']
126
+ when 'example_pending'
127
+ worker.pending += 1
128
+ worker.current_example = event['example']
129
+ when 'example_failed'
130
+ worker.failed += 1
131
+ worker.current_example = event['example']
132
+ end
133
+
134
+ @dirty = true
135
+ end
136
+
137
+ def poll_once
138
+ @event_files.each do |process_number, path|
139
+ next unless File.exist?(path)
140
+
141
+ File.open(path, 'r') do |file|
142
+ file.seek(@event_offsets[process_number])
143
+ chunk = file.read.to_s
144
+ @event_offsets[process_number] = file.pos
145
+ next if chunk.empty?
146
+
147
+ buffer = @event_remainders[process_number] << chunk
148
+ lines = buffer.split("\n", -1)
149
+ @event_remainders[process_number] = lines.pop.to_s
150
+
151
+ lines.each do |line|
152
+ next if line.empty?
153
+
154
+ process_event(process_number, JSON.parse(line))
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ def frame
161
+ lines = if interactive?
162
+ [header_line, *workers.map { |worker| worker_line(worker) }]
163
+ else
164
+ [plain_header_line, *workers.map { |worker| plain_worker_line(worker) }]
165
+ end
166
+ "#{lines.join("\n")}\n"
167
+ end
168
+
169
+ private
170
+
171
+ def synchronize(&block)
172
+ @mutex.synchronize(&block)
173
+ end
174
+
175
+ def event_file_context
176
+ @event_files.map { |process_number, path| "worker #{process_number + 1}=#{path}" }.join(', ')
177
+ end
178
+
179
+ def render
180
+ if interactive?
181
+ render_interactive
182
+ else
183
+ render_plain
184
+ end
185
+ @dirty = false
186
+ end
187
+
188
+ def render_interactive
189
+ output = frame
190
+ if @rendered_lines > 0
191
+ @output.print("\e[#{@rendered_lines}A")
192
+ @output.print("\r\e[J")
193
+ end
194
+ @output.print(output)
195
+ @output.flush if @output.respond_to?(:flush)
196
+ @rendered_lines = output.lines.count
197
+ end
198
+
199
+ def render_plain
200
+ @output.print(frame)
201
+ @output.flush if @output.respond_to?(:flush)
202
+ end
203
+
204
+ def header_line
205
+ running = workers.count { |worker| status_for(worker) == :running }
206
+ passed = workers.count { |worker| status_for(worker) == :passed }
207
+ failed = workers.count { |worker| [:failing, :failed].include?(status_for(worker)) }
208
+ examples_seen = workers.sum { |worker| examples_seen_for(worker) }
209
+ total_examples = workers.filter_map(&:example_total)
210
+ example_summary = if total_examples.empty?
211
+ "examples: #{examples_seen}"
212
+ else
213
+ summary = "examples: #{examples_seen}/#{total_examples.sum}"
214
+ summary += ' known' unless total_examples.size == workers.size
215
+ summary
216
+ end
217
+
218
+ truncate(
219
+ [
220
+ 'Parallel RSpec dashboard',
221
+ "workers: #{workers.size}",
222
+ "running: #{running}",
223
+ "passed: #{passed}",
224
+ "failed: #{failed}",
225
+ example_summary,
226
+ "elapsed: #{format_duration(elapsed_seconds)}"
227
+ ].join(' | '),
228
+ terminal_width
229
+ )
230
+ end
231
+
232
+ def worker_line(worker)
233
+ plain_status = status_text_for(worker)
234
+ colored_status = colorize(plain_status.ljust(9), status_color_for(worker))
235
+ line = format(
236
+ "[%<label>2d] %<status>s %<bar>s %<summary>9s p:%<passed>3d f:%<failed>3d pend:%<pending>3d",
237
+ label: worker.label,
238
+ status: colored_status,
239
+ bar: progress_bar_for(worker),
240
+ summary: progress_summary_for(worker),
241
+ passed: worker.passed,
242
+ failed: worker.failed,
243
+ pending: worker.pending
244
+ )
245
+ line += " | #{worker.current_example}" if worker.current_example
246
+ truncate(line, terminal_width)
247
+ end
248
+
249
+ def plain_header_line
250
+ running = workers.count { |worker| status_for(worker) == :running }
251
+ passed = workers.count { |worker| status_for(worker) == :passed }
252
+ failed = workers.count { |worker| [:failing, :failed].include?(status_for(worker)) }
253
+ examples_seen = workers.sum { |worker| examples_seen_for(worker) }
254
+ total_examples = workers.filter_map(&:example_total)
255
+
256
+ parts = [
257
+ 'dashboard',
258
+ "workers=#{workers.size}",
259
+ "running=#{running}",
260
+ "passed=#{passed}",
261
+ "failed=#{failed}",
262
+ "examples_seen=#{examples_seen}",
263
+ "elapsed=#{format_duration(elapsed_seconds)}"
264
+ ]
265
+
266
+ unless total_examples.empty?
267
+ parts << "examples_total=#{total_examples.sum}"
268
+ parts << "examples_known=#{total_examples.size == workers.size}"
269
+ end
270
+
271
+ parts.join(' ')
272
+ end
273
+
274
+ def plain_worker_line(worker)
275
+ parts = [
276
+ "worker=#{worker.label}",
277
+ "status=#{plain_status_text_for(worker)}",
278
+ "passed=#{worker.passed}",
279
+ "failed=#{worker.failed}",
280
+ "pending=#{worker.pending}",
281
+ "current_example=#{encode_plain_value(worker.current_example.to_s)}"
282
+ ]
283
+
284
+ if worker.example_total
285
+ parts << "completed=#{examples_seen_for(worker)}"
286
+ parts << "total=#{worker.example_total}"
287
+ else
288
+ parts << "files=#{worker.files_count}"
289
+ end
290
+
291
+ parts.join(' ')
292
+ end
293
+
294
+ def progress_summary_for(worker)
295
+ if worker.example_total
296
+ format('%<completed>d/%<total>d', completed: examples_seen_for(worker), total: worker.example_total)
297
+ else
298
+ format('files:%<count>3d', count: worker.files_count)
299
+ end
300
+ end
301
+
302
+ def progress_bar_for(worker)
303
+ return "[#{'.' * PROGRESS_BAR_WIDTH}]" unless worker.example_total
304
+
305
+ total = worker.example_total
306
+ completed = [examples_seen_for(worker), total].min
307
+ filled = if total == 0
308
+ PROGRESS_BAR_WIDTH
309
+ else
310
+ [(completed.to_f / total * PROGRESS_BAR_WIDTH).round, PROGRESS_BAR_WIDTH].min
311
+ end
312
+ empty = PROGRESS_BAR_WIDTH - filled
313
+ "[#{'#' * filled}#{'-' * empty}]"
314
+ end
315
+
316
+ def examples_seen_for(worker)
317
+ worker.passed + worker.failed + worker.pending
318
+ end
319
+
320
+ def status_for(worker)
321
+ if worker.finished_at
322
+ if worker.failed > 0 || worker.exit_status.to_i != 0
323
+ :failed
324
+ else
325
+ :passed
326
+ end
327
+ elsif worker.failed > 0
328
+ :failing
329
+ elsif worker.started_at
330
+ :running
331
+ else
332
+ :queued
333
+ end
334
+ end
335
+
336
+ def status_text_for(worker)
337
+ case status_for(worker)
338
+ when :queued then '· queued'
339
+ when :running then "#{SPINNER[@spinner_index % SPINNER.length]} running"
340
+ when :failing then '! failing'
341
+ when :failed then '✗ failed'
342
+ when :passed then '✓ passed'
343
+ end
344
+ end
345
+
346
+ def plain_status_text_for(worker)
347
+ case status_for(worker)
348
+ when :queued then 'queued'
349
+ when :running then 'running'
350
+ when :failing then 'failing'
351
+ when :failed then 'failed'
352
+ when :passed then 'passed'
353
+ end
354
+ end
355
+
356
+ def status_color_for(worker)
357
+ case status_for(worker)
358
+ when :queued then 90
359
+ when :running then 33
360
+ when :failing, :failed then 31
361
+ when :passed then 32
362
+ end
363
+ end
364
+
365
+ def encode_plain_value(value)
366
+ URI.encode_www_form_component(value)
367
+ end
368
+
369
+ def colorize(text, color)
370
+ return text unless @use_colors
371
+
372
+ "\e[#{color}m#{text}\e[0m"
373
+ end
374
+
375
+ def interactive?
376
+ @mode == :interactive
377
+ end
378
+
379
+ def elapsed_seconds
380
+ return 0 unless @started_at
381
+
382
+ (@now.call - @started_at).to_i
383
+ end
384
+
385
+ def format_duration(seconds)
386
+ hours = seconds / 3600
387
+ minutes = (seconds % 3600) / 60
388
+ remaining_seconds = seconds % 60
389
+
390
+ if hours > 0
391
+ format('%<hours>d:%<minutes>02d:%<seconds>02d', hours: hours, minutes: minutes, seconds: remaining_seconds)
392
+ else
393
+ format('%<minutes>02d:%<seconds>02d', minutes: minutes, seconds: remaining_seconds)
394
+ end
395
+ end
396
+
397
+ def terminal_width
398
+ @terminal_width ||= @width || begin
399
+ console = IO.console
400
+ console&.winsize&.last || 120
401
+ rescue StandardError
402
+ 120
403
+ end
404
+ end
405
+
406
+ def truncate(text, max_length)
407
+ return '' if max_length <= 0
408
+ return text if text.length <= max_length
409
+ return text[0, max_length] if max_length <= 1
410
+
411
+ "#{text[0, max_length - 1]}…"
412
+ end
413
+ end
414
+ end
415
+ end