parallel_specs 0.9.0 → 0.9.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31a954499f658d2d18df5d6126b522cf299985b33f68e5016d8a19de4e1369bd
4
- data.tar.gz: 80789703549b936ae3b97309b0979a72d6799f10cd3ab604f11b7f3ed316509c
3
+ metadata.gz: 7220b40d98f5c203c37cc69d12e9145e909dd4877d35e77fb0ae011f23ab3d3e
4
+ data.tar.gz: 40eadee9e0eb4748c3c17ad8c4b0bf016328c4564d655e014c8a0e22fbc5ae81
5
5
  SHA512:
6
- metadata.gz: f994089098548075cccb2591e4e56dee64aee54fd861b92a30573a62e1358323b1bbf300af645f8aab23e6a5334c0f0b27cd834f61ffc118575f8c3e4737c83f
7
- data.tar.gz: b6cf4496f6703d1d11d9375408bf718b7adf5c74327c8a9f12212b2956e17aabbb234f752092dbe44f4a97f5f9510351b32b5733f4f4646d8cd80ca6388bdbcb
6
+ metadata.gz: 83ae2b21bd87c2445be0a1af045df3a2e0563a84eded7c62d08cb47792f7bc73d55f6416dea0aaa37b3ae49ebac3876804eb391c5219ff4c521e88074188aed5
7
+ data.tar.gz: 12bef5dbb566c689b5721f5a88339a69c3936eae64f1251977b4e1c847de2ab41c5e518d911ea31a4405321bbf9a2fabc6dd35d5942cc44ce35c7ec0e0015d76
data/README.md CHANGED
@@ -9,16 +9,47 @@ A focused extraction of the RSpec pieces from `parallel_tests` with only the par
9
9
  - runtime-log generation for balanced spec splitting
10
10
  - modern Ruby only
11
11
 
12
+ ## Setup
13
+
14
+ Add the gem to your app's Gemfile:
15
+
16
+ ```ruby
17
+ group :test do
18
+ gem 'parallel_specs'
19
+ end
20
+ ```
21
+
22
+ Then install it:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ For Rails apps, configure the test database name to include `TEST_ENV_NUMBER`. The first worker uses a blank value, then workers 2, 3, and so on use their worker number:
29
+
30
+ ```yaml
31
+ # config/database.yml
32
+ test:
33
+ database: my_app_test<%= ENV['TEST_ENV_NUMBER'] %>
34
+ ```
35
+
36
+ If you previously used `parallel_tests`, this is the same database naming convention. Once the `parallel_specs` Rake tasks are available, you can remove `parallel_tests` if you were only keeping it for database setup.
37
+
12
38
  ## Commands
13
39
 
14
40
  ```bash
15
41
  bundle exec parallel_specs
16
42
  bundle exec parallel_specs -n 6
43
+ bundle exec parallel_specs --dashboard-mode plain
44
+ bundle exec parallel_specs --plain
45
+ bundle exec parallel_specs --plain-dashboard
17
46
  bundle exec parallel_specs --test-options='--tag ~type:system'
18
47
  bundle exec parallel_specs --record-runtime
19
48
  ```
20
49
 
21
- Local TTY runs render the interactive dashboard. CI and other non-TTY runs automatically fall back to the plain text summary.
50
+ Local TTY runs render the interactive dashboard. CI and other non-TTY runs automatically fall back to the plain text summary. Use `--plain`, `--dashboard-mode plain`, or `--plain-dashboard` to force plain dashboard output without setting an environment variable.
51
+
52
+ Plain dashboard output is intentionally minimal: it prints the examples counter and elapsed time, then relies on the process exit status and the final RSpec summary for success or failure. It does not print per-worker rows or current example names.
22
53
 
23
54
  ## Runtime balancing
24
55
 
@@ -40,15 +71,70 @@ bundle exec parallel_specs --record-runtime --runtime-log tmp/my_runtime.log
40
71
 
41
72
  `--runtime-log PATH` is used as the input path for balancing and, with `--record-runtime`, as the output destination for the completed run.
42
73
 
74
+ ## Rails database tasks
75
+
76
+ Rails apps that use `TEST_ENV_NUMBER` in `config/database.yml` can prepare per-worker test databases without depending on `parallel_tests`.
77
+
78
+ The most common workflow is:
79
+
80
+ ```bash
81
+ # Create all worker databases once
82
+ bundle exec rake parallel:create
83
+
84
+ # Load the current schema into each worker database
85
+ bundle exec rake parallel:load_schema
86
+
87
+ # Run the specs
88
+ bundle exec parallel_specs
89
+ ```
90
+
91
+ After changing migrations, refresh the worker databases with:
92
+
93
+ ```bash
94
+ bundle exec rake parallel:prepare
95
+ ```
96
+
97
+ `parallel:prepare` checks for pending migrations, dumps the schema or structure once, and then loads it into each worker database.
98
+
99
+ To reset everything from scratch:
100
+
101
+ ```bash
102
+ bundle exec rake parallel:drop
103
+ bundle exec rake parallel:create
104
+ bundle exec rake parallel:load_schema
105
+ ```
106
+
107
+ Each task accepts an optional worker count:
108
+
109
+ ```bash
110
+ bundle exec rake 'parallel:create[4]'
111
+ bundle exec rake 'parallel:prepare[4]'
112
+ bundle exec parallel_specs -n 4
113
+ ```
114
+
115
+ If no count is provided, the tasks use `PARALLEL_SPECS_PROCESSORS` or the detected processor count. Use the same count for database prep and spec runs.
116
+
117
+ The gem also exposes compatible `parallel:setup`, `parallel:migrate`, `parallel:rollback`, `parallel:load_structure`, `parallel:seed`, and `parallel:rake` tasks.
118
+
119
+ Set `PARALLEL_SPECS_RAILS_ENV` to prepare an environment other than `test`.
120
+
43
121
  ## Environment variables
44
122
 
45
123
  The supported environment variables are:
46
124
 
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.
125
+ - `PARALLEL_SPECS_PROCESSORS` sets the default worker count when `-n` is not provided and when Rails database tasks are run without a count.
126
+ - `PARALLEL_SPECS_DASHBOARD_MODE` can be `interactive` or `plain` to override automatic dashboard selection. You can also use `--plain`, `--dashboard-mode plain`, or `--plain-dashboard` for a single run.
49
127
  - `PARALLEL_SPECS_HEARTBEAT_INTERVAL` sets the plain-dashboard heartbeat interval in seconds.
128
+ - `PARALLEL_SPECS_FULL_RERUN_COMMANDS=1` prints full failed-worker rerun commands even when they are long.
129
+ - `PARALLEL_SPECS_RERUN_COMMAND_SPEC_FILE_LIMIT` sets how many spec files a failed-worker rerun command may include before it is summarized instead of printed. The default is 25.
130
+ - `PARALLEL_SPECS_RERUN_COMMAND_CHAR_LIMIT` sets the maximum failed-worker rerun command length before it is summarized instead of printed. The default is 2000.
131
+ - `PARALLEL_SPECS_RAILS_ENV` sets the Rails environment used by the `parallel:*` database tasks. The default is `test`.
50
132
  - `CI` makes dashboard output default to plain mode.
51
133
 
52
134
  Worker processes continue to receive `TEST_ENV_NUMBER` for compatibility with existing test-environment isolation setup.
53
135
 
54
136
  This gem does not intentionally preserve the old `parallel_tests` executable names, formatter paths, or environment variable aliases.
137
+
138
+ ## Acknowledgements
139
+
140
+ This gem is largely based on the excellent work from [`parallel_tests`](https://github.com/grosser/parallel_tests), especially its approach to grouping test files, assigning `TEST_ENV_NUMBER`, and preparing per-worker Rails test databases.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'io/console'
5
- require 'uri'
3
+ require "json"
4
+ require "io/console"
5
+ require "strscan"
6
6
 
7
7
  module ParallelSpecs
8
8
  class CLI
@@ -20,7 +20,7 @@ module ParallelSpecs
20
20
  :exit_status
21
21
  )
22
22
 
23
- SPINNER = ['-', '\\', '|', '/'].freeze
23
+ SPINNER = ["-", "\\", "|", "/"].freeze
24
24
  PROGRESS_BAR_WIDTH = 24
25
25
  REFRESH_INTERVAL = 0.1
26
26
 
@@ -45,7 +45,8 @@ module ParallelSpecs
45
45
  @event_offsets = Hash.new(0)
46
46
  @event_remainders = Hash.new { |hash, key| hash[key] = +"" }
47
47
  @spinner_index = 0
48
- @rendered_lines = 0
48
+ @rendered_rows = 0
49
+ @last_frame = nil
49
50
  @dirty = true
50
51
  end
51
52
 
@@ -70,7 +71,7 @@ module ParallelSpecs
70
71
  render if @dirty
71
72
  end
72
73
  end
73
- rescue StandardError => e
74
+ rescue => e
74
75
  @running = false
75
76
  warn "parallel_specs: dashboard refresh failed while polling #{event_file_context}: #{e.class}: #{e.message}"
76
77
  end
@@ -84,7 +85,7 @@ module ParallelSpecs
84
85
  @mutex.synchronize do
85
86
  begin
86
87
  poll_once
87
- rescue StandardError => e
88
+ rescue => e
88
89
  warn "parallel_specs: dashboard final poll failed while polling #{event_file_context}: #{e.class}: #{e.message}"
89
90
  end
90
91
  render
@@ -115,20 +116,20 @@ module ParallelSpecs
115
116
  worker = @workers.fetch(process_number)
116
117
  worker.started_at ||= @now.call
117
118
 
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'
119
+ case event.fetch("event")
120
+ when "start"
121
+ worker.example_total = event["total"]
122
+ when "example_started"
123
+ worker.current_example = event["example"]
124
+ when "example_passed"
124
125
  worker.passed += 1
125
- worker.current_example = event['example']
126
- when 'example_pending'
126
+ worker.current_example = event["example"]
127
+ when "example_pending"
127
128
  worker.pending += 1
128
- worker.current_example = event['example']
129
- when 'example_failed'
129
+ worker.current_example = event["example"]
130
+ when "example_failed"
130
131
  worker.failed += 1
131
- worker.current_example = event['example']
132
+ worker.current_example = event["example"]
132
133
  end
133
134
 
134
135
  @dirty = true
@@ -138,7 +139,7 @@ module ParallelSpecs
138
139
  @event_files.each do |process_number, path|
139
140
  next unless File.exist?(path)
140
141
 
141
- File.open(path, 'r') do |file|
142
+ File.open(path, "r") do |file|
142
143
  file.seek(@event_offsets[process_number])
143
144
  chunk = file.read.to_s
144
145
  @event_offsets[process_number] = file.pos
@@ -151,7 +152,11 @@ module ParallelSpecs
151
152
  lines.each do |line|
152
153
  next if line.empty?
153
154
 
154
- process_event(process_number, JSON.parse(line))
155
+ begin
156
+ process_event(process_number, JSON.parse(line))
157
+ rescue JSON::ParserError, KeyError => e
158
+ warn "parallel_specs: dashboard event ignored while polling worker #{process_number + 1}=#{path}: #{e.class}: #{e.message}"
159
+ end
155
160
  end
156
161
  end
157
162
  end
@@ -161,7 +166,7 @@ module ParallelSpecs
161
166
  lines = if interactive?
162
167
  [header_line, *workers.map { |worker| worker_line(worker) }]
163
168
  else
164
- [plain_header_line, *workers.map { |worker| plain_worker_line(worker) }]
169
+ [plain_header_line]
165
170
  end
166
171
  "#{lines.join("\n")}\n"
167
172
  end
@@ -173,7 +178,7 @@ module ParallelSpecs
173
178
  end
174
179
 
175
180
  def event_file_context
176
- @event_files.map { |process_number, path| "worker #{process_number + 1}=#{path}" }.join(', ')
181
+ @event_files.map { |process_number, path| "worker #{process_number + 1}=#{path}" }.join(", ")
177
182
  end
178
183
 
179
184
  def render
@@ -186,14 +191,20 @@ module ParallelSpecs
186
191
  end
187
192
 
188
193
  def render_interactive
194
+ @render_width = terminal_width
189
195
  output = frame
190
- if @rendered_lines > 0
191
- @output.print("\e[#{@rendered_lines}A")
196
+ rows_to_clear = [@rendered_rows, rendered_rows_for(@last_frame, @render_width)].max
197
+
198
+ if rows_to_clear.positive?
199
+ @output.print("\e[#{rows_to_clear}A")
192
200
  @output.print("\r\e[J")
193
201
  end
194
202
  @output.print(output)
195
203
  @output.flush if @output.respond_to?(:flush)
196
- @rendered_lines = output.lines.count
204
+ @rendered_rows = rendered_rows_for(output, @render_width)
205
+ @last_frame = output
206
+ ensure
207
+ @render_width = nil
197
208
  end
198
209
 
199
210
  def render_plain
@@ -202,29 +213,18 @@ module ParallelSpecs
202
213
  end
203
214
 
204
215
  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
216
  examples_seen = workers.sum { |worker| examples_seen_for(worker) }
209
217
  total_examples = workers.filter_map(&:example_total)
210
218
  example_summary = if total_examples.empty?
211
219
  "examples: #{examples_seen}"
212
220
  else
213
221
  summary = "examples: #{examples_seen}/#{total_examples.sum}"
214
- summary += ' known' unless total_examples.size == workers.size
222
+ summary += " known" unless total_examples.size == workers.size
215
223
  summary
216
224
  end
217
225
 
218
226
  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(' | '),
227
+ [example_summary, "elapsed: #{format_duration(elapsed_seconds)}"].join(" | "),
228
228
  terminal_width
229
229
  )
230
230
  end
@@ -242,65 +242,23 @@ module ParallelSpecs
242
242
  failed: worker.failed,
243
243
  pending: worker.pending
244
244
  )
245
- line += " | #{worker.current_example}" if worker.current_example
246
245
  truncate(line, terminal_width)
247
246
  end
248
247
 
249
248
  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(' ')
249
+ header_line
292
250
  end
293
251
 
294
252
  def progress_summary_for(worker)
295
253
  if worker.example_total
296
- format('%<completed>d/%<total>d', completed: examples_seen_for(worker), total: worker.example_total)
254
+ format("%<completed>d/%<total>d", completed: examples_seen_for(worker), total: worker.example_total)
297
255
  else
298
- format('files:%<count>3d', count: worker.files_count)
256
+ format("files:%<count>3d", count: worker.files_count)
299
257
  end
300
258
  end
301
259
 
302
260
  def progress_bar_for(worker)
303
- return "[#{'.' * PROGRESS_BAR_WIDTH}]" unless worker.example_total
261
+ return "[#{"." * PROGRESS_BAR_WIDTH}]" unless worker.example_total
304
262
 
305
263
  total = worker.example_total
306
264
  completed = [examples_seen_for(worker), total].min
@@ -310,7 +268,7 @@ module ParallelSpecs
310
268
  [(completed.to_f / total * PROGRESS_BAR_WIDTH).round, PROGRESS_BAR_WIDTH].min
311
269
  end
312
270
  empty = PROGRESS_BAR_WIDTH - filled
313
- "[#{'#' * filled}#{'-' * empty}]"
271
+ "[#{"#" * filled}#{"-" * empty}]"
314
272
  end
315
273
 
316
274
  def examples_seen_for(worker)
@@ -335,21 +293,11 @@ module ParallelSpecs
335
293
 
336
294
  def status_text_for(worker)
337
295
  case status_for(worker)
338
- when :queued then '· queued'
296
+ when :queued then "· queued"
339
297
  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'
298
+ when :failing then "! failing"
299
+ when :failed then "✗ failed"
300
+ when :passed then "✓ passed"
353
301
  end
354
302
  end
355
303
 
@@ -362,10 +310,6 @@ module ParallelSpecs
362
310
  end
363
311
  end
364
312
 
365
- def encode_plain_value(value)
366
- URI.encode_www_form_component(value)
367
- end
368
-
369
313
  def colorize(text, color)
370
314
  return text unless @use_colors
371
315
 
@@ -388,27 +332,63 @@ module ParallelSpecs
388
332
  remaining_seconds = seconds % 60
389
333
 
390
334
  if hours > 0
391
- format('%<hours>d:%<minutes>02d:%<seconds>02d', hours: hours, minutes: minutes, seconds: remaining_seconds)
335
+ format("%<hours>d:%<minutes>02d:%<seconds>02d", hours: hours, minutes: minutes, seconds: remaining_seconds)
392
336
  else
393
- format('%<minutes>02d:%<seconds>02d', minutes: minutes, seconds: remaining_seconds)
337
+ format("%<minutes>02d:%<seconds>02d", minutes: minutes, seconds: remaining_seconds)
394
338
  end
395
339
  end
396
340
 
397
341
  def terminal_width
398
- @terminal_width ||= @width || begin
342
+ return @render_width if @render_width
343
+
344
+ width = if @width.respond_to?(:call)
345
+ @width.call
346
+ elsif @width
347
+ @width
348
+ else
399
349
  console = IO.console
400
350
  console&.winsize&.last || 120
401
- rescue StandardError
402
- 120
351
+ end
352
+
353
+ [width.to_i - 1, 1].max
354
+ rescue
355
+ 119
356
+ end
357
+
358
+ def rendered_rows_for(output, width)
359
+ return 0 if output.to_s.empty?
360
+
361
+ output.lines.sum do |line|
362
+ visible_width = visible_length(line.chomp)
363
+ [((visible_width - 1) / width) + 1, 1].max
403
364
  end
404
365
  end
405
366
 
406
367
  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
368
+ return "" if max_length <= 0
369
+ return text if visible_length(text) <= max_length
370
+ return "…" if max_length <= 1
371
+
372
+ truncated = +""
373
+ visible_count = 0
374
+ scanner = StringScanner.new(text)
375
+
376
+ until scanner.eos? || visible_count >= max_length - 1
377
+ if (escape_sequence = scanner.scan(/\e\[[\d;]*m/))
378
+ truncated << escape_sequence
379
+ else
380
+ truncated << scanner.getch
381
+ visible_count += 1
382
+ end
383
+ end
384
+
385
+ truncated << "…"
386
+ truncated << "\e[0m" if text.include?("\e[")
387
+ truncated
388
+ end
410
389
 
411
- "#{text[0, max_length - 1]}…"
390
+ def visible_length(text)
391
+ text.gsub(/\e\[[\d;]*m/, "").length
412
392
  end
413
393
  end
414
394
  end