rspec-sharder 0.0.4 → 0.0.5

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: c3d09ff7bef003d57985622045c99bb8743834d26f0f2119bc32d9ffdd800822
4
- data.tar.gz: 9e5f371699047b82862fe5f76288122c10b80b23117dc0ec2d82e6e1f29f312e
3
+ metadata.gz: 456e3e2d41127ed5154b26a4dffaa32e6fabf159a9aaea4461630212a8209dee
4
+ data.tar.gz: 37d68db5f6317a270de6f96129842c5bf4e530cedfab973b03d53c16583a74b9
5
5
  SHA512:
6
- metadata.gz: be15f6f4f0b66eef6b50af889e48f3fb3f7cadfd1650314538c3088e4828e19f8f6af77092fa359f081cec206f3957389397e1d52b4e30ff81dd20b2ecb854cd
7
- data.tar.gz: 4c69946dc056cb146ff452d80b9e31503c12fbaa6cfb14f164268fafd84a7baf13da04b694e77f0b9d9dfea65898f8579f2b7fb81b401bde7ef80b9494f458d8
6
+ metadata.gz: 5ca04689abae34a1a1820ac1de65fb844377254251ba3e4f6420858873bed207140495846b23ea9a00c3ffa8c796747cca5023f7c463f2c5bb78f3fe760c1879
7
+ data.tar.gz: 43bbba87bf483eb6237a16c12c115ff1b1ef340b7fb527ec4a6d4e0c2bded1a8d794379e9b9cd6223040e7cb89c7e39d86014733198c0d7117d78233f38382b9
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-sharder (0.0.3)
4
+ rspec-sharder (0.0.4)
5
5
  rspec-core
6
6
 
7
7
  GEM
@@ -1,5 +1,5 @@
1
1
  require 'optparse'
2
- require 'rspec-sharder'
2
+ require 'rspec-sharder/runner'
3
3
 
4
4
  def fail(message)
5
5
  warn message
@@ -83,4 +83,4 @@ fail('fatal: invalid value for --total-shards') unless @total_shards > 0
83
83
  fail('fatal: invalid value for --shard') unless @shard > 0
84
84
  fail('fatal: --shard may not be greater than --total-shards') unless @shard <= @total_shards
85
85
 
86
- exit RSpec::Sharder.run(total_shards: @total_shards, shard_num: @shard, persist: @persist, rspec_args: ARGV)
86
+ exit RSpec::Sharder::Runner.run(total_shards: @total_shards, shard_num: @shard, persist: @persist, rspec_args: ARGV)
@@ -0,0 +1,181 @@
1
+ require 'rspec/core'
2
+ require 'rspec-sharder/sharder'
3
+
4
+ module RSpec
5
+ module Sharder
6
+ module Runner
7
+ def self.run(total_shards:, shard_num:, persist:, rspec_args:)
8
+ raise "fatal: invalid total shards: #{total_shards}" unless total_shards.is_a?(Integer) && total_shards > 0
9
+ raise "fatal: invalid shard number: #{shard_num}" unless shard_num.is_a?(Integer) && shard_num > 0 && shard_num <= total_shards
10
+
11
+ begin
12
+ ::RSpec::Core::ConfigurationOptions.new(rspec_args).configure(::RSpec.configuration)
13
+
14
+ return if ::RSpec.world.wants_to_quit
15
+
16
+ ::RSpec.configuration.load_spec_files
17
+ ensure
18
+ ::RSpec.world.announce_filters
19
+ end
20
+
21
+ return ::RSpec.configuration.reporter.exit_early(::RSpec.configuration.failure_exit_code) if ::RSpec.world.wants_to_quit
22
+
23
+ begin
24
+ shards = ::RSpec::Sharder.build_shards(total_shards)
25
+ rescue ::RSpec::Sharder::ShardError => e
26
+ ::RSpec.configuration.error_stream.puts e.message
27
+ return ::RSpec.configuration.reporter.exit_early(::RSpec.configuration.failure_exit_code)
28
+ end
29
+
30
+ print_shards(shards)
31
+
32
+ expected_total_duration = shards[shard_num - 1][:duration]
33
+
34
+ shard_file_paths = shards[shard_num - 1][:file_paths]
35
+ example_groups = ::RSpec.world.ordered_example_groups.select do |example_group|
36
+ shard_file_paths.include?(example_group.metadata[:file_path])
37
+ end
38
+ example_count = ::RSpec.world.example_count(example_groups)
39
+
40
+ new_durations = { }
41
+
42
+ actual_total_duration = 0
43
+ exit_code = ::RSpec.configuration.reporter.report(example_count) do |reporter|
44
+ ::RSpec.configuration.with_suite_hooks do
45
+ if example_count == 0 && ::RSpec.configuration.fail_if_no_examples
46
+ return ::RSpec.configuration.failure_exit_code
47
+ end
48
+
49
+ group_results = example_groups.map do |example_group|
50
+ result, duration = run_example_group(example_group, reporter)
51
+
52
+ file_path = example_group.metadata[:file_path]
53
+ actual_total_duration += duration
54
+ new_durations[file_path] ||= 0
55
+ new_durations[file_path] += duration
56
+
57
+ result
58
+ end
59
+
60
+ success = group_results.all?
61
+ exit_code = success ? 0 : 1
62
+ if ::RSpec.world.non_example_failure
63
+ success = false
64
+ exit_code = ::RSpec.configuration.failure_exit_code
65
+ end
66
+ exit_code
67
+ end
68
+ end
69
+
70
+ # Write results to .examples file.
71
+ unless ::RSpec.configuration.dry_run
72
+ persist_example_statuses(shard_file_paths)
73
+ end
74
+
75
+ if ::RSpec.configuration.dry_run
76
+ if persist
77
+ ::RSpec.configuration.output_stream.puts <<~EOF
78
+
79
+ Dry run. Not saving to .rspec-sharder-durations.
80
+ EOF
81
+ end
82
+ else
83
+ if exit_code == 0
84
+ # Print recorded durations and summary.
85
+ ::RSpec.configuration.output_stream.puts 'Durations'
86
+
87
+ new_durations.sort_by { |file_path, duration| file_path }.each do |file_path, duration|
88
+ ::RSpec.configuration.output_stream.puts "#{file_path},#{duration}"
89
+ end
90
+
91
+ ::RSpec.configuration.output_stream.puts <<~EOF
92
+
93
+ Expected total duration: #{pretty_duration(expected_total_duration)}
94
+ Actual total duration: #{pretty_duration(actual_total_duration)}
95
+ Diff: #{pretty_duration((actual_total_duration - expected_total_duration).abs)}
96
+ EOF
97
+
98
+ if persist
99
+ # Write durations to .rspec-sharder-durations.
100
+ ::RSpec.configuration.output_stream.puts <<~EOF
101
+
102
+ Saving to .rspec-sharder-durations.
103
+ EOF
104
+
105
+ persist_durations(new_durations)
106
+ end
107
+ elsif persist
108
+ ::RSpec.configuration.output_stream.puts <<~EOF
109
+
110
+ RSpec failed. Not saving to .rspec-sharder-durations.
111
+ EOF
112
+ end
113
+ end
114
+
115
+ exit_code
116
+ end
117
+
118
+ private
119
+
120
+ def self.persist_example_statuses(file_paths)
121
+ return unless (path = ::RSpec.configuration.example_status_persistence_file_path)
122
+
123
+ examples = ::RSpec.world.all_examples.select do |example|
124
+ file_paths.include?(example.metadata[:file_path])
125
+ end
126
+ ::RSpec::Core::ExampleStatusPersister.persist(examples, path)
127
+ rescue SystemCallError => e
128
+ ::RSpec.configuration.output_stream.puts "warning: failed to write results to #{path}"
129
+ end
130
+
131
+ def self.pretty_duration(duration_millis)
132
+ duration_seconds = (duration_millis / 1000.0).round
133
+ minutes = duration_seconds / 60
134
+ seconds = duration_seconds % 60
135
+
136
+ minutes_str = "#{minutes} minute#{minutes == 1 ? '' : 's'}"
137
+ seconds_str = "#{seconds} second#{seconds == 1 ? '' : 's'}"
138
+
139
+ if minutes == 0
140
+ seconds_str
141
+ else
142
+ "#{minutes_str}, #{seconds_str}"
143
+ end
144
+ end
145
+
146
+ def self.print_shards(shards)
147
+ shards.each_with_index do |shard, i|
148
+ ::RSpec.configuration.output_stream.puts(
149
+ "Shard #{i + 1} (Files: #{shard[:file_paths].size}, Duration: #{pretty_duration(shard[:duration])}):"
150
+ )
151
+ shard[:file_paths].each do |file_path|
152
+ ::RSpec.configuration.output_stream.puts file_path
153
+ end
154
+ ::RSpec.configuration.output_stream.puts unless i == shards.size - 1
155
+ end
156
+ end
157
+
158
+ def self.persist_durations(durations)
159
+ File.open(".rspec-sharder-durations", "w+") do |file|
160
+ file.puts <<~EOF
161
+ # Generated by rspec-sharder on #{Time.now.to_s}. See `bundle exec rspec-sharder -h`.
162
+
163
+ EOF
164
+ durations.sort_by { |file_path, duration| file_path }.each do |file_path, duration|
165
+ file.puts "#{file_path},#{duration}"
166
+ end
167
+ end
168
+ end
169
+
170
+ def self.current_time_millis
171
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
172
+ end
173
+
174
+ def self.run_example_group(example_group, reporter)
175
+ start_time = current_time_millis
176
+ result = example_group.run(reporter)
177
+ [result, (current_time_millis - start_time).to_i]
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,96 @@
1
+ require 'rspec/core'
2
+
3
+ module RSpec
4
+ module Sharder
5
+ class ShardError < StandardError; end
6
+
7
+ def self.load_recorded_durations
8
+ durations = { }
9
+
10
+ missing_files = 0
11
+
12
+ if File.exist?('.rspec-sharder-durations')
13
+ File.readlines('.rspec-sharder-durations').each_with_index do |line, index|
14
+ line = line.strip
15
+
16
+ if !line.start_with?('#') && !line.empty?
17
+ parts = line.split(',')
18
+
19
+ unless parts.length == 2
20
+ raise ShardError.new("fatal: invalid .rspec-sharder-durations at line #{index + 1}")
21
+ end
22
+
23
+ file_path = parts[0].strip
24
+
25
+ if file_path.empty?
26
+ raise ShardError.new("fatal: invalid file path in .rspec-sharder-durations at line #{index + 1}")
27
+ end
28
+
29
+ unless File.exist?(file_path)
30
+ missing_files += 1
31
+ end
32
+
33
+ begin
34
+ duration = Integer(parts[1])
35
+ rescue ArgumentError => e
36
+ raise ShardError.new("fatal: invalid .rspec-sharder-durations at line #{index + 1}")
37
+ end
38
+
39
+ durations[file_path] = duration
40
+ end
41
+ end.compact
42
+
43
+ if missing_files > 0
44
+ ::RSpec.configuration.output_stream.puts <<~EOF
45
+ warning: #{missing_files} file(s) in .rspec-sharder-durations do not exist, consider regenerating
46
+
47
+ EOF
48
+ end
49
+ end
50
+
51
+ durations
52
+ end
53
+
54
+ def self.build_shards(total_shards)
55
+ durations = load_recorded_durations
56
+
57
+ files = { }
58
+
59
+ missing_files = 0
60
+ ::RSpec.world.ordered_example_groups.each do |example_group|
61
+ file_path = example_group.metadata[:file_path]
62
+ files[file_path] ||= 0
63
+ if durations[file_path]
64
+ files[file_path] = durations[file_path]
65
+ else
66
+ missing_files += 1
67
+ # Assume 1000 milliseconds per example.
68
+ files[file_path] += ::RSpec.world.example_count([example_group]) * 1000
69
+ end
70
+ end
71
+
72
+ if missing_files > 0
73
+ ::RSpec.configuration.output_stream.puts <<~EOF
74
+ warning: #{missing_files} file(s) in not found in .rspec-sharder-durations, consider regenerating
75
+
76
+ EOF
77
+ end
78
+
79
+ shards = (1..total_shards).map { { duration: 0, file_paths: [] } }
80
+
81
+ # First sort by duration to ensure large files are distributed evenly.
82
+ # Next, sort by path to ensure shards are generated deterministically.
83
+ # Note that files is a map, sorting it turns it into an array of arrays.
84
+ files = files.sort_by { |file_path, duration| [duration, file_path] }.reverse
85
+ files.each do |file_path, duration|
86
+ shards.sort_by! { |shard| shard[:duration] }
87
+ shards[0][:file_paths] << file_path
88
+ shards[0][:duration] += duration
89
+ end
90
+
91
+ shards.each { |shard| shard[:file_paths].sort! }
92
+
93
+ shards
94
+ end
95
+ end
96
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Sharder
5
- VERSION = "0.0.4"
5
+ VERSION = "0.0.5"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-sharder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Dower
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-08-29 00:00:00.000000000 Z
11
+ date: 2021-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec-core
@@ -38,8 +38,9 @@ files:
38
38
  - LICENSE
39
39
  - README.md
40
40
  - bin/rspec-sharder
41
- - lib/rspec-sharder.rb
42
41
  - lib/rspec-sharder/command.rb
42
+ - lib/rspec-sharder/runner.rb
43
+ - lib/rspec-sharder/sharder.rb
43
44
  - lib/rspec-sharder/version.rb
44
45
  - rspec-sharder.gemspec
45
46
  - scripts/release.sh
data/lib/rspec-sharder.rb DELETED
@@ -1,270 +0,0 @@
1
- require 'rspec/core'
2
-
3
- module RSpec
4
- module Sharder
5
-
6
- class ShardError < StandardError; end
7
-
8
- def self.run(total_shards:, shard_num:, persist:, rspec_args:)
9
- raise "fatal: invalid total shards: #{total_shards}" unless total_shards.is_a?(Integer) && total_shards > 0
10
- raise "fatal: invalid shard number: #{shard_num}" unless shard_num.is_a?(Integer) && shard_num > 0 && shard_num <= total_shards
11
-
12
- begin
13
- ::RSpec::Core::ConfigurationOptions.new(rspec_args).configure(::RSpec.configuration)
14
-
15
- return if ::RSpec.world.wants_to_quit
16
-
17
- ::RSpec.configuration.load_spec_files
18
- ensure
19
- ::RSpec.world.announce_filters
20
- end
21
-
22
- return ::RSpec.configuration.reporter.exit_early(::RSpec.configuration.failure_exit_code) if ::RSpec.world.wants_to_quit
23
-
24
- all_durations = load_recorded_durations
25
-
26
- begin
27
- shards = build_shards(total_shards, shard_num, all_durations)
28
- rescue ShardError => e
29
- ::RSpec.configuration.error_stream.puts e.message
30
- return ::RSpec.configuration.reporter.exit_early(::RSpec.configuration.failure_exit_code)
31
- end
32
-
33
- print_shards(shards)
34
-
35
- expected_total_duration = shards[shard_num - 1][:duration]
36
-
37
- shard_file_paths = shards[shard_num - 1][:file_paths]
38
- example_groups = ::RSpec.world.ordered_example_groups.select do |example_group|
39
- shard_file_paths.include?(example_group.metadata[:file_path])
40
- end
41
- example_count = ::RSpec.world.example_count(example_groups)
42
-
43
- new_durations = { }
44
-
45
- actual_total_duration = 0
46
- exit_code = ::RSpec.configuration.reporter.report(example_count) do |reporter|
47
- ::RSpec.configuration.with_suite_hooks do
48
- if example_count == 0 && ::RSpec.configuration.fail_if_no_examples
49
- return ::RSpec.configuration.failure_exit_code
50
- end
51
-
52
- group_results = example_groups.map do |example_group|
53
- result, duration = run_example_group(example_group, reporter)
54
-
55
- file_path = example_group.metadata[:file_path]
56
- actual_total_duration += duration
57
- new_durations[file_path] ||= 0
58
- new_durations[file_path] += duration
59
-
60
- result
61
- end
62
-
63
- success = group_results.all?
64
- exit_code = success ? 0 : 1
65
- if ::RSpec.world.non_example_failure
66
- success = false
67
- exit_code = ::RSpec.configuration.failure_exit_code
68
- end
69
- exit_code
70
- end
71
- end
72
-
73
- # Write results to .examples file.
74
- unless ::RSpec.configuration.dry_run
75
- persist_example_statuses(shard_file_paths)
76
- end
77
-
78
- if ::RSpec.configuration.dry_run
79
- if persist
80
- ::RSpec.configuration.output_stream.puts <<~EOF
81
-
82
- Dry run. Not saving to .rspec-sharder-durations.
83
- EOF
84
- end
85
- else
86
- if exit_code == 0
87
- # Print recorded durations and summary.
88
- ::RSpec.configuration.output_stream.puts 'Durations'
89
-
90
- new_durations.sort_by { |file_path, duration| file_path }.each do |file_path, duration|
91
- ::RSpec.configuration.output_stream.puts "#{file_path},#{duration}"
92
- end
93
-
94
- ::RSpec.configuration.output_stream.puts <<~EOF
95
-
96
- Expected total duration: #{pretty_duration(expected_total_duration)}
97
- Actual total duration: #{pretty_duration(actual_total_duration)}
98
- Diff: #{pretty_duration((actual_total_duration - expected_total_duration).abs)}
99
- EOF
100
-
101
- if persist
102
- # Write durations to .rspec-sharder-durations.
103
- ::RSpec.configuration.output_stream.puts <<~EOF
104
-
105
- Saving to .rspec-sharder-durations.
106
- EOF
107
-
108
- persist_durations(new_durations)
109
- end
110
- elsif persist
111
- ::RSpec.configuration.output_stream.puts <<~EOF
112
-
113
- RSpec failed. Not saving to .rspec-sharder-durations.
114
- EOF
115
- end
116
- end
117
-
118
- exit_code
119
- end
120
-
121
- private
122
-
123
- def self.load_recorded_durations
124
- durations = { }
125
-
126
- missing_files = 0
127
-
128
- if File.exist?('.rspec-sharder-durations')
129
- File.readlines('.rspec-sharder-durations').each_with_index do |line, index|
130
- line = line.strip
131
-
132
- if !line.start_with?('#') && !line.empty?
133
- parts = line.split(',')
134
-
135
- unless parts.length == 2
136
- raise ShardError.new("fatal: invalid .rspec-sharder-durations at line #{index + 1}")
137
- end
138
-
139
- file_path = parts[0].strip
140
-
141
- if file_path.empty?
142
- raise ShardError.new("fatal: invalid file path in .rspec-sharder-durations at line #{index + 1}")
143
- end
144
-
145
- unless File.exist?(file_path)
146
- missing_files += 1
147
- end
148
-
149
- begin
150
- duration = Integer(parts[1])
151
- rescue ArgumentError => e
152
- raise ShardError.new("fatal: invalid .rspec-sharder-durations at line #{index + 1}")
153
- end
154
-
155
- durations[file_path] = duration
156
- end
157
- end.compact
158
-
159
- if missing_files > 0
160
- ::RSpec.configuration.output_stream.puts <<~EOF
161
- warning: #{missing_files} file(s) in .rspec-sharder-durations do not exist, consider regenerating
162
-
163
- EOF
164
- end
165
- end
166
-
167
- durations
168
- end
169
-
170
- def self.build_shards(total_shards, shard_num, durations)
171
- files = { }
172
-
173
- missing_files = 0
174
- ::RSpec.world.ordered_example_groups.each do |example_group|
175
- file_path = example_group.metadata[:file_path]
176
- files[file_path] ||= 0
177
- if durations[file_path]
178
- files[file_path] = durations[file_path]
179
- else
180
- missing_files += 1
181
- # Assume 1000 milliseconds per example.
182
- files[file_path] += ::RSpec.world.example_count([example_group]) * 1000
183
- end
184
- end
185
-
186
- if missing_files > 0
187
- ::RSpec.configuration.output_stream.puts <<~EOF
188
- warning: #{missing_files} file(s) in not found in .rspec-sharder-durations, consider regenerating
189
-
190
- EOF
191
- end
192
-
193
- shards = (1..total_shards).map { { duration: 0, file_paths: [] } }
194
-
195
- # First sort by duration to ensure large files are distributed evenly.
196
- # Next, sort by path to ensure shards are generated deterministically.
197
- # Note that files is a map, sorting it turns it into an array of arrays.
198
- files = files.sort_by { |file_path, duration| [duration, file_path] }.reverse
199
- files.each do |file_path, duration|
200
- shards.sort_by! { |shard| shard[:duration] }
201
- shards[0][:file_paths] << file_path
202
- shards[0][:duration] += duration
203
- end
204
-
205
- shards.each { |shard| shard[:file_paths].sort! }
206
-
207
- shards
208
- end
209
-
210
- def self.persist_example_statuses(file_paths)
211
- return unless (path = ::RSpec.configuration.example_status_persistence_file_path)
212
-
213
- examples = ::RSpec.world.all_examples.select do |example|
214
- file_paths.include?(example.metadata[:file_path])
215
- end
216
- ::RSpec::Core::ExampleStatusPersister.persist(examples, path)
217
- rescue SystemCallError => e
218
- ::RSpec.configuration.output_stream.puts "warning: failed to write results to #{path}"
219
- end
220
-
221
- def self.pretty_duration(duration_millis)
222
- duration_seconds = (duration_millis / 1000.0).round
223
- minutes = duration_seconds / 60
224
- seconds = duration_seconds % 60
225
-
226
- minutes_str = "#{minutes} minute#{minutes == 1 ? '' : 's'}"
227
- seconds_str = "#{seconds} second#{seconds == 1 ? '' : 's'}"
228
-
229
- if minutes == 0
230
- seconds_str
231
- else
232
- "#{minutes_str}, #{seconds_str}"
233
- end
234
- end
235
-
236
- def self.print_shards(shards)
237
- shards.each_with_index do |shard, i|
238
- ::RSpec.configuration.output_stream.puts(
239
- "Shard #{i + 1} (Files: #{shard[:file_paths].size}, Duration: #{pretty_duration(shard[:duration])}):"
240
- )
241
- shard[:file_paths].each do |file_path|
242
- ::RSpec.configuration.output_stream.puts file_path
243
- end
244
- ::RSpec.configuration.output_stream.puts unless i == shards.size - 1
245
- end
246
- end
247
-
248
- def self.persist_durations(durations)
249
- File.open(".rspec-sharder-durations", "w+") do |file|
250
- file.puts <<~EOF
251
- # Generated by rspec-sharder on #{Time.now.to_s}. See `bundle exec rspec-sharder -h`.
252
-
253
- EOF
254
- durations.sort_by { |file_path, duration| file_path }.each do |file_path, duration|
255
- file.puts "#{file_path},#{duration}"
256
- end
257
- end
258
- end
259
-
260
- def self.current_time_millis
261
- (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
262
- end
263
-
264
- def self.run_example_group(example_group, reporter)
265
- start_time = current_time_millis
266
- result = example_group.run(reporter)
267
- [result, (current_time_millis - start_time).to_i]
268
- end
269
- end
270
- end