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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parallel_specs'
4
+ require 'parallel_specs/rspec/logger_base'
5
+
6
+ class ParallelSpecs::RSpec::RuntimeLogger < ParallelSpecs::RSpec::LoggerBase
7
+ RSpec::Core::Formatters.register(self, :example_group_started, :example_group_finished, :start_dump)
8
+
9
+ def initialize(*args)
10
+ super
11
+ @example_times = Hash.new(0)
12
+ @group_nesting = 0
13
+ end
14
+
15
+ def example_group_started(example_group)
16
+ @time = ParallelSpecs.now if @group_nesting.zero?
17
+ @group_nesting += 1
18
+ super
19
+ end
20
+
21
+ def example_group_finished(notification)
22
+ @group_nesting -= 1
23
+ if @group_nesting.zero?
24
+ @example_times[notification.group.file_path] += ParallelSpecs.now - @time
25
+ end
26
+ super if defined?(super)
27
+ end
28
+
29
+ def seed(*); end
30
+ def dump_summary(*); end
31
+ def dump_failures(*); end
32
+ def dump_failure(*); end
33
+ def dump_pending(*); end
34
+
35
+ def start_dump(*)
36
+ return unless ENV['TEST_ENV_NUMBER']
37
+
38
+ lock_output do
39
+ @example_times.sort_by(&:last).reverse_each do |file, time|
40
+ relative_path = file.sub(%r{^#{Regexp.escape(Dir.pwd)}/}, '').sub(%r{^\./}, '')
41
+ @output.puts "#{relative_path}:#{[time, 0].max}"
42
+ end
43
+ end
44
+ @output.flush
45
+ end
46
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parallel_specs'
4
+ require 'shellwords'
5
+
6
+ module ParallelSpecs
7
+ module Test
8
+ class Runner
9
+ RuntimeLogTooSmallError = Class.new(StandardError)
10
+ RuntimeLogParseError = Class.new(StandardError)
11
+
12
+ class << self
13
+ def tests_in_groups(tests, num_groups, options = {})
14
+ ParallelSpecs::Grouper.in_even_groups_by_size(tests_with_size(tests, options), num_groups)
15
+ end
16
+
17
+ def tests_with_size(tests, options)
18
+ tests = find_tests(tests, options)
19
+
20
+ case options[:group_by]
21
+ when :found
22
+ tests.map! { |test| [test, 1] }
23
+ when :runtime
24
+ sort_by_runtime(
25
+ tests,
26
+ runtimes(tests, options),
27
+ options.merge(allowed_missing: (options[:allowed_missing_percent] || 50) / 100.0)
28
+ )
29
+ when :filesize
30
+ sort_by_filesize(tests)
31
+ when nil
32
+ begin
33
+ known_runtimes = runtimes(tests, options)
34
+ rescue Errno::ENOENT
35
+ warn "parallel_specs: runtime log #{runtime_log_path(options)} was not found; falling back to filesize grouping" if options[:runtime_log]
36
+ known_runtimes = {}
37
+ rescue RuntimeLogParseError => e
38
+ warn "parallel_specs: unable to use runtime log #{runtime_log_path(options)}: #{e.message}; falling back to filesize grouping"
39
+ known_runtimes = {}
40
+ rescue StandardError => e
41
+ warn "parallel_specs: unable to load runtime log #{runtime_log_path(options)}: #{e.class}: #{e.message}"
42
+ raise
43
+ end
44
+
45
+ if known_runtimes.size * 1.5 > tests.size
46
+ puts 'Using recorded test runtime'
47
+ sort_by_runtime(tests, known_runtimes)
48
+ else
49
+ sort_by_filesize(tests)
50
+ end
51
+ else
52
+ raise ArgumentError, "Unsupported option #{options[:group_by]}"
53
+ end
54
+
55
+ tests
56
+ end
57
+
58
+ def execute_command(cmd, process_number, num_processes, options)
59
+ env = {
60
+ 'TEST_ENV_NUMBER' => test_env_number(process_number),
61
+ 'PARALLEL_SPECS_GROUPS' => num_processes.to_s,
62
+ 'PARALLEL_SPECS_PID_FILE' => ParallelSpecs.pid_file_path
63
+ }
64
+
65
+ if (dashboard_event_files = options[:dashboard_event_files])
66
+ env['PARALLEL_SPECS_DASHBOARD_EVENT_LOG'] = dashboard_event_files.fetch(process_number)
67
+ end
68
+
69
+ execute_command_and_capture_output(env, cmd, options)
70
+ end
71
+
72
+ def execute_command_and_capture_output(env, cmd, options)
73
+ pid = nil
74
+ output = IO.popen(env, cmd, err: [:child, :out]) do |io|
75
+ pid = io.pid
76
+ ParallelSpecs.pids.add(pid)
77
+ capture_output(io, options[:dashboard])
78
+ ensure
79
+ ParallelSpecs.pids.delete(pid) if pid
80
+ end
81
+
82
+ status = $?
83
+ exit_status = if status.exitstatus
84
+ status.exitstatus
85
+ elsif status.termsig
86
+ status.termsig + 128
87
+ else
88
+ 1
89
+ end
90
+
91
+ { env: env, stdout: output, exit_status: exit_status, command: cmd, seed: seed_from(output) }
92
+ end
93
+
94
+ def print_command(command, env = {})
95
+ env_string = rerun_env(env).map { |key, value| "#{key}=#{Shellwords.escape(value)}" }.join(' ')
96
+ command_string = Shellwords.shelljoin(command)
97
+ puts [env_string, command_string].reject(&:empty?).join(' ')
98
+ end
99
+
100
+ def rerun_command(command, seed: nil)
101
+ seed ? command_with_seed(command, seed) : command
102
+ end
103
+
104
+ def command_with_seed(command, seed)
105
+ [*remove_command_arguments(command, '--seed'), '--seed', seed]
106
+ end
107
+
108
+ def find_results(test_output)
109
+ test_output.lines.filter_map do |line|
110
+ line = line.chomp.gsub(/\e\[\d+m/, '')
111
+ line if line_is_result?(line)
112
+ end
113
+ end
114
+
115
+ def summarize_results(results)
116
+ sum_up_results(results).sort.map { |word, count| "#{count} #{word}#{'s' if count != 1}" }.join(', ')
117
+ end
118
+
119
+ protected
120
+
121
+ def capture_output(out, dashboard)
122
+ result = +''
123
+ begin
124
+ loop do
125
+ chunk = out.readpartial(1_000_000)
126
+ chunk = chunk.force_encoding(Encoding.default_internal) if Encoding.default_internal
127
+ result << chunk
128
+ next if dashboard
129
+
130
+ $stdout.print(chunk)
131
+ $stdout.flush
132
+ end
133
+ rescue EOFError
134
+ nil
135
+ end
136
+ result
137
+ end
138
+
139
+ def seed_from(output)
140
+ output.to_s[/seed (\d+)/, 1]
141
+ end
142
+
143
+ def sort_by_runtime(tests, runtimes, options = {})
144
+ allowed_missing = tests.size * (options[:allowed_missing] || 1.0)
145
+ tests.sort!
146
+ tests.map! do |test|
147
+ time = runtimes[test]
148
+ allowed_missing -= 1 unless time
149
+ if allowed_missing.negative?
150
+ log = options[:runtime_log] || runtime_log
151
+ raise RuntimeLogTooSmallError, "Runtime log file '#{log}' does not contain sufficient data to sort #{tests.size} test files, please update or remove it."
152
+ end
153
+ [test, time]
154
+ end
155
+
156
+ set_unknown_runtime(tests, options)
157
+ end
158
+
159
+ def runtimes(tests, options)
160
+ path = runtime_log_path(options)
161
+ File.read(path).split("\n").each_with_index.each_with_object({}) do |(line, index), times|
162
+ next if line.empty?
163
+
164
+ test, separator, time = line.rpartition(':')
165
+ raise RuntimeLogParseError, "Invalid runtime log line #{index + 1} in #{path}: #{line.inspect}" if separator.empty? || test.empty? || time.empty?
166
+
167
+ times[test] = Float(time) if tests.include?(test)
168
+ rescue ArgumentError
169
+ raise RuntimeLogParseError, "Invalid runtime value on line #{index + 1} in #{path}: #{line.inspect}"
170
+ end
171
+ end
172
+
173
+ def runtime_log_path(options)
174
+ options[:runtime_log] || runtime_log
175
+ end
176
+
177
+ def sort_by_filesize(tests)
178
+ tests.sort!
179
+ tests.map! { |test| [test, File.stat(test).size] }
180
+ end
181
+
182
+ def find_tests(tests, options = {})
183
+ tests.flat_map do |file_or_folder|
184
+ if File.directory?(file_or_folder)
185
+ filter_files(Dir[File.join(file_or_folder, '**/*_spec.rb')].uniq.sort, options)
186
+ else
187
+ filter_files([file_or_folder], options)
188
+ end
189
+ end.uniq
190
+ end
191
+
192
+ def filter_files(files, options)
193
+ files = files.grep(options[:pattern]) if options[:pattern]
194
+ files = files.reject { |file| file.match?(options[:exclude_pattern]) } if options[:exclude_pattern]
195
+ files
196
+ end
197
+
198
+ def remove_command_arguments(command, *args)
199
+ remove_next = false
200
+ command.reject do |arg|
201
+ if remove_next
202
+ remove_next = false
203
+ true
204
+ elsif args.include?(arg)
205
+ remove_next = true
206
+ true
207
+ elsif args.any? { |option| arg.start_with?("#{option}=") }
208
+ true
209
+ else
210
+ false
211
+ end
212
+ end
213
+ end
214
+
215
+ def rerun_env(env)
216
+ env.slice('TEST_ENV_NUMBER', 'PARALLEL_SPECS_GROUPS').reject { |_key, value| value.to_s.empty? }
217
+ end
218
+
219
+ def test_env_number(process_number)
220
+ process_number.zero? ? '' : (process_number + 1).to_s
221
+ end
222
+
223
+ def sum_up_results(results)
224
+ results.join(' ').gsub(/s\b/, '').scan(/(\d+) (\w+)/).each_with_object(Hash.new(0)) do |(number, word), sum|
225
+ sum[word] += number.to_i
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ def set_unknown_runtime(tests, options)
232
+ known, unknown = tests.partition(&:last)
233
+ return tests if unknown.empty?
234
+
235
+ unknown_runtime = options[:unknown_runtime] || (known.empty? ? 1 : known.map!(&:last).sum / known.size)
236
+ unknown.each { |entry| entry[1] = unknown_runtime }
237
+ tests
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelSpecs
4
+ VERSION = '0.9.0'
5
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parallel'
4
+ require 'rbconfig'
5
+ require 'tempfile'
6
+
7
+ module ParallelSpecs
8
+ WINDOWS = (RbConfig::CONFIG['host_os'] =~ /cygwin|mswin|mingw|bccwin|wince|emx/)
9
+ RUBY_BINARY = File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name'])
10
+
11
+ autoload :CLI, 'parallel_specs/cli'
12
+ autoload :VERSION, 'parallel_specs/version'
13
+ autoload :Grouper, 'parallel_specs/grouper'
14
+ autoload :Pids, 'parallel_specs/pids'
15
+
16
+ class << self
17
+ def determine_number_of_processes(count)
18
+ Integer([
19
+ count,
20
+ ENV['PARALLEL_SPECS_PROCESSORS'],
21
+ Parallel.processor_count
22
+ ].detect { |value| !value.to_s.strip.empty? })
23
+ end
24
+
25
+ def with_pid_file
26
+ previous_pid_file = ENV['PARALLEL_SPECS_PID_FILE']
27
+ Tempfile.open('parallel_specs-pidfile') do |file|
28
+ ENV['PARALLEL_SPECS_PID_FILE'] = file.path
29
+ @pids = pids
30
+ yield
31
+ ensure
32
+ ENV['PARALLEL_SPECS_PID_FILE'] = previous_pid_file
33
+ @pids = nil
34
+ end
35
+ end
36
+
37
+ def pids
38
+ @pids ||= Pids.new(pid_file_path)
39
+ end
40
+
41
+ def pid_file_available?
42
+ !ENV['PARALLEL_SPECS_PID_FILE'].to_s.empty?
43
+ end
44
+
45
+ def pid_file_path
46
+ ENV.fetch('PARALLEL_SPECS_PID_FILE')
47
+ end
48
+
49
+ def stop_all_processes
50
+ return false unless pid_file_available?
51
+
52
+ tracked_pids = pids.all
53
+ return false if tracked_pids.empty?
54
+
55
+ signal_delivered = false
56
+ tracked_pids.each do |pid|
57
+ Process.kill(:INT, pid)
58
+ signal_delivered = true
59
+ rescue Errno::ESRCH, Errno::EPERM => e
60
+ warn "parallel_specs: unable to interrupt worker pid #{pid}: #{e.class}: #{e.message}"
61
+ end
62
+ signal_delivered
63
+ end
64
+
65
+ def bundler_enabled?
66
+ return true if Object.const_defined?(:Bundler)
67
+
68
+ previous = nil
69
+ current = File.expand_path(Dir.pwd)
70
+ until !File.directory?(current) || current == previous
71
+ return true if File.exist?(File.join(current, 'Gemfile'))
72
+
73
+ previous = current
74
+ current = File.expand_path('..', current)
75
+ end
76
+
77
+ false
78
+ end
79
+
80
+ def with_ruby_binary(command)
81
+ WINDOWS ? [RUBY_BINARY, '--', command] : [command]
82
+ end
83
+
84
+ def now
85
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ end
87
+
88
+ def delta
89
+ before = now.to_f
90
+ yield
91
+ now.to_f - before
92
+ end
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: parallel_specs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Scott Watermasysk
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: parallel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.28'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.28'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rspec-core
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '4'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '3.13'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '4'
53
+ description:
54
+ email: scottwater@gmail.com
55
+ executables:
56
+ - parallel_specs
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - LICENSE
61
+ - README.md
62
+ - bin/parallel_specs
63
+ - lib/parallel_specs.rb
64
+ - lib/parallel_specs/cli.rb
65
+ - lib/parallel_specs/cli/dashboard.rb
66
+ - lib/parallel_specs/grouper.rb
67
+ - lib/parallel_specs/pids.rb
68
+ - lib/parallel_specs/rspec/dashboard_logger.rb
69
+ - lib/parallel_specs/rspec/logger_base.rb
70
+ - lib/parallel_specs/rspec/runner.rb
71
+ - lib/parallel_specs/rspec/runtime_logger.rb
72
+ - lib/parallel_specs/test/runner.rb
73
+ - lib/parallel_specs/version.rb
74
+ homepage: https://github.com/scottwater/parallel_specs
75
+ licenses:
76
+ - MIT
77
+ metadata: {}
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.2.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.4.19
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Parallel RSpec with a live dashboard, plain CI output, and runtime balancing
97
+ test_files: []