rspec-sharder 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
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