rspec-sharder 0.0.1 → 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: 6d525960fabd4fd6d3c83ce963601ca0552bbcb15e11c212cdf872de2cd71a36
4
- data.tar.gz: 5c7591851f241b1ab2b017ad81f3488bd5ac6dea21c1978df4e2cb963bd17bf2
3
+ metadata.gz: 456e3e2d41127ed5154b26a4dffaa32e6fabf159a9aaea4461630212a8209dee
4
+ data.tar.gz: 37d68db5f6317a270de6f96129842c5bf4e530cedfab973b03d53c16583a74b9
5
5
  SHA512:
6
- metadata.gz: 1c65df78f7e5d973308808db3c48309c14d3e0abd1d7b5ccc37d1faa1d2f82b1ff98acba5a38f39947f47927cffaedf4dfe3f4b744b4cceb11959df2762b5bf7
7
- data.tar.gz: db806df9aaa7f00cf7f62a2f583908a1d98864d2dd34b93a61a477c35716fa55f3f42c2cf9f22b9be49474e0d164f01776f2d1d8fbe4ce7ecbc05c9ebb3ab9dd
6
+ metadata.gz: 5ca04689abae34a1a1820ac1de65fb844377254251ba3e4f6420858873bed207140495846b23ea9a00c3ffa8c796747cca5023f7c463f2c5bb78f3fe760c1879
7
+ data.tar.gz: 43bbba87bf483eb6237a16c12c115ff1b1ef340b7fb527ec4a6d4e0c2bded1a8d794379e9b9cd6223040e7cb89c7e39d86014733198c0d7117d78233f38382b9
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ rspec-sharder-*.gem
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspec-sharder (0.0.1)
4
+ rspec-sharder (0.0.4)
5
5
  rspec-core
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -5,22 +5,35 @@ Groups specs into shards, ensuring that each shard has a similar size, and runs
5
5
  the specified shard.
6
6
 
7
7
  Shard size is determined by summing the saved durations for each spec file in
8
- the shard. Durations are saved in .spec_durations. If a spec file is not found
9
- in .spec_durations, the duration is estimated based on the number of examples in
10
- the spec file.
8
+ the shard. Durations are saved in .rspec-sharder-durations. If a spec file is
9
+ not found in .rspec-sharder-durations, the duration is estimated based on the
10
+ number of examples in the spec file.
11
11
 
12
- .spec_durations is generate/updated after a successful run when --persist is
13
- specified, but only for the shard which was actually executed. To generate
14
- durations for all shards simultaneously, run with the default options of 1 total
15
- shards and --persist:
12
+ .rspec-sharder-durations is generated after each successful run, but only for
13
+ the specs which were run as part of the specified shard. Before first use,
14
+ generate .rspec-sharder-durations for all specs by executing with the default
15
+ option of 1 total shards:
16
16
 
17
- bundle exec rspec-sharder --persist -- [<rspec-args...>]
17
+ bundle exec rspec-sharder -- [<rspec-args...>]
18
18
 
19
- Usage: bundle exec rspec-sharder [--total-shards <num> [--shard <num>]] [--persist] -- [<rspec-args...>]
19
+ Commit .rspec-sharder-durations file to your version control.
20
+
21
+ Next, configure 2 or more CI jobs to execute separate shards like:
22
+
23
+ bundle exec rspec-sharder --total-shards 4 --shard 1 -- [<rspec-args...>]
24
+ bundle exec rspec-sharder --total-shards 4 --shard 2 -- [<rspec-args...>]
25
+ bundle exec rspec-sharder --total-shards 4 --shard 3 -- [<rspec-args...>]
26
+ bundle exec rspec-sharder --total-shards 4 --shard 4 -- [<rspec-args...>]
27
+
28
+ Finally, set up some job or process to periodically pull .rspec-sharder-durations
29
+ files from CI, combine them, and commit them to source control. This will ensure
30
+ you pick up updated durations for any new or changed files.
31
+
32
+ Usage: bundle exec rspec-sharder [--total-shards <num> [--shard <num>]] [--no-persist] -- [<rspec-args...>]
20
33
 
21
34
  Options:
22
35
  -h, --help Print this message.
23
36
  -t, --total-shards <num> The total number of shards. Defaults to 1.
24
37
  -s, --shard <num> The shard to run. Defaults to 1.
25
- -p, --persist Save durations to .spec_durations.
38
+ --no-persist Don't save durations to .rspec-sharder-durations.
26
39
  ```
data/bin/rspec-sharder CHANGED
@@ -1,75 +1,3 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'optparse'
4
- require 'rspec-sharder'
5
-
6
- def fail(message)
7
- warn message
8
- puts
9
- puts @parser.help
10
- exit 1
11
- end
12
-
13
- @total_shards = 1
14
- @shard_num = 1
15
- @persist = false
16
-
17
- @parser = OptionParser.new do |opts|
18
- opts.banner = <<~EOF
19
- Groups specs into shards, ensuring that each shard has a similar size, and runs
20
- the specified shard.
21
-
22
- Shard size is determined by summing the saved durations for each spec file in
23
- the shard. Durations are saved in .spec_durations. If a spec file is not found
24
- in .spec_durations, the duration is estimated based on the number of examples in
25
- the spec file.
26
-
27
- .spec_durations is generate/updated after a successful run when --persist is
28
- specified, but only for the shard which was actually executed. To generate
29
- durations for all shards simultaneously, run with the default options of 1 total
30
- shards and --persist:
31
-
32
- bundle exec rspec-sharder --persist -- [<rspec-args...>]
33
-
34
- Usage: bundle exec rspec-sharder [--total-shards <num> [--shard <num>]] [--persist] -- [<rspec-args...>]
35
-
36
- Options:
37
- EOF
38
-
39
- opts.on('-h', '--help', "Print this message.") do
40
- puts opts
41
- exit
42
- end
43
-
44
- opts.on('-t', '--total-shards <num>', 'The total number of shards. Defaults to 1.') do |total_shards|
45
- begin
46
- @total_shards = Integer(total_shards)
47
- rescue ArgumentError
48
- fail('fatal: invalid value for --total-shards')
49
- end
50
- end
51
-
52
- opts.on('-s', '--shard <num>', 'The shard to run. Defaults to 1.') do |shard|
53
- begin
54
- @shard = Integer(shard)
55
- rescue ArgumentError
56
- fail('fatal: invalid value for --shard')
57
- end
58
- end
59
-
60
- opts.on('-p', '--persist', 'Save durations to .spec_durations.') do
61
- @persist = true
62
- end
63
- end
64
-
65
- begin
66
- @parser.parse!
67
- rescue StandardError => e
68
- fail("fatal: #{e.message}")
69
- end
70
-
71
- fail('fatal: invalid value for --total-shards') unless @total_shards > 0
72
- fail('fatal: invalid value for --shard') unless @shard > 0
73
- fail('fatal: --shard may not be greater than --total-shards') unless @shard <= @total_shards
74
-
75
- RSpec::Sharder.run(total_shards: @total_shards, shard_num: @shard, persist: @persist, rspec_args: ARGV)
3
+ require 'rspec-sharder/command'
@@ -0,0 +1,86 @@
1
+ require 'optparse'
2
+ require 'rspec-sharder/runner'
3
+
4
+ def fail(message)
5
+ warn message
6
+ puts
7
+ puts @parser.help
8
+ exit 1
9
+ end
10
+
11
+ @total_shards = 1
12
+ @shard = 1
13
+ @persist = true
14
+
15
+ @parser = OptionParser.new do |opts|
16
+ opts.banner = <<~EOF
17
+ Groups specs into shards, ensuring that each shard has a similar size, and runs
18
+ the specified shard.
19
+
20
+ Shard size is determined by summing the saved durations for each spec file in
21
+ the shard. Durations are saved in .rspec-sharder-durations. If a spec file is
22
+ not found in .rspec-sharder-durations, the duration is estimated based on the
23
+ number of examples in the spec file.
24
+
25
+ .rspec-sharder-durations is generated after each successful run, but only for
26
+ the specs which were run as part of the specified shard. Before first use,
27
+ generate .rspec-sharder-durations for all specs by executing with the default
28
+ option of 1 total shards:
29
+
30
+ bundle exec rspec-sharder -- [<rspec-args...>]
31
+
32
+ Commit .rspec-sharder-durations file to your version control.
33
+
34
+ Next, configure 2 or more CI jobs to execute separate shards like:
35
+
36
+ bundle exec rspec-sharder --total-shards 4 --shard 1 -- [<rspec-args...>]
37
+ bundle exec rspec-sharder --total-shards 4 --shard 2 -- [<rspec-args...>]
38
+ bundle exec rspec-sharder --total-shards 4 --shard 3 -- [<rspec-args...>]
39
+ bundle exec rspec-sharder --total-shards 4 --shard 4 -- [<rspec-args...>]
40
+
41
+ Finally, set up some job or process to periodically pull .rspec-sharder-durations
42
+ files from CI, combine them, and commit them to source control. This will ensure
43
+ you pick up updated durations for any new or changed files.
44
+
45
+ Usage: bundle exec rspec-sharder [--total-shards <num> [--shard <num>]] [--no-persist] -- [<rspec-args...>]
46
+
47
+ Options:
48
+ EOF
49
+
50
+ opts.on('-h', '--help', "Print this message.") do
51
+ puts opts
52
+ exit
53
+ end
54
+
55
+ opts.on('-t', '--total-shards <num>', 'The total number of shards. Defaults to 1.') do |total_shards|
56
+ begin
57
+ @total_shards = Integer(total_shards)
58
+ rescue ArgumentError
59
+ fail('fatal: invalid value for --total-shards')
60
+ end
61
+ end
62
+
63
+ opts.on('-s', '--shard <num>', 'The shard to run. Defaults to 1.') do |shard|
64
+ begin
65
+ @shard = Integer(shard)
66
+ rescue ArgumentError
67
+ fail('fatal: invalid value for --shard')
68
+ end
69
+ end
70
+
71
+ opts.on('--no-persist', "Don't save durations to .rspec-sharder-durations.") do
72
+ @persist = false
73
+ end
74
+ end
75
+
76
+ begin
77
+ @parser.parse!
78
+ rescue StandardError => e
79
+ fail("fatal: #{e.message}")
80
+ end
81
+
82
+ fail('fatal: invalid value for --total-shards') unless @total_shards > 0
83
+ fail('fatal: invalid value for --shard') unless @shard > 0
84
+ fail('fatal: --shard may not be greater than --total-shards') unless @shard <= @total_shards
85
+
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.1"
5
+ VERSION = "0.0.5"
6
6
  end
7
7
  end
data/scripts/release.sh CHANGED
@@ -15,6 +15,8 @@ fi
15
15
  NEW_VERSION=$1
16
16
  CURRENT_VERSION=$(grep VERSION lib/rspec-sharder/version.rb | cut -d'"' -f 2)
17
17
 
18
+ bundle exec rspec
19
+
18
20
  echo "Updating from v$CURRENT_VERSION to v$NEW_VERSION. Press enter to continue."
19
21
  read
20
22
 
@@ -23,4 +25,5 @@ gem build
23
25
  gem push rspec-sharder-$NEW_VERSION.gem
24
26
  bundle install
25
27
  git commit -a -m "v$NEW_VERSION Release"
28
+ git push
26
29
  open "https://github.com/nicholasdower/rspec-sharder/releases/new?title=v$NEW_VERSION%20Release&tag=v$NEW_VERSION&target=$(git rev-parse HEAD)"
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.1
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-28 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
@@ -32,12 +32,15 @@ executables:
32
32
  extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
+ - ".gitignore"
35
36
  - Gemfile
36
37
  - Gemfile.lock
37
38
  - LICENSE
38
39
  - README.md
39
40
  - bin/rspec-sharder
40
- - lib/rspec-sharder.rb
41
+ - lib/rspec-sharder/command.rb
42
+ - lib/rspec-sharder/runner.rb
43
+ - lib/rspec-sharder/sharder.rb
41
44
  - lib/rspec-sharder/version.rb
42
45
  - rspec-sharder.gemspec
43
46
  - scripts/release.sh
data/lib/rspec-sharder.rb DELETED
@@ -1,261 +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
- exit ::RSpec.configuration.failure_exit_cod
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
- start_time = current_time_millis
54
- result = example_group.run(reporter)
55
- end_time = current_time_millis
56
-
57
- file_path = example_group.metadata[:file_path]
58
- duration = (end_time - start_time).to_i
59
- actual_total_duration += duration
60
- new_durations[file_path] ||= 0
61
- new_durations[file_path] += duration
62
-
63
- result
64
- end
65
-
66
- success = group_results.all?
67
- exit_code = success ? 0 : 1
68
- if ::RSpec.world.non_example_failure
69
- success = false
70
- exit_code = ::RSpec.configuration.failure_exit_code
71
- end
72
- exit_code
73
- end
74
- end
75
-
76
- # Write results to .examples file.
77
- unless ::RSpec.configuration.dry_run
78
- persist_example_statuses(shard_file_paths)
79
- end
80
-
81
- if ::RSpec.configuration.dry_run
82
- if persist
83
- ::RSpec.configuration.output_stream.puts <<~EOF
84
-
85
- Dry run. Not saving to .spec_durations.
86
- EOF
87
- end
88
- else
89
- if exit_code == 0
90
- # Print recorded durations and summary.
91
- ::RSpec.configuration.output_stream.puts <<~EOF
92
-
93
- Durations:
94
- EOF
95
-
96
- new_durations.sort_by { |file_path, duration| file_path }.each do |file_path, duration|
97
- ::RSpec.configuration.output_stream.puts "#{file_path},#{duration}"
98
- end
99
-
100
- ::RSpec.configuration.output_stream.puts <<~EOF
101
-
102
- Expected total duration: #{pretty_duration(expected_total_duration)}
103
- Actual total duration: #{pretty_duration(actual_total_duration)}
104
- Diff: #{pretty_duration((actual_total_duration - expected_total_duration).abs)}
105
- EOF
106
-
107
- if persist
108
- # Write all durations with updates to .spec_durations.
109
- ::RSpec.configuration.output_stream.puts <<~EOF
110
-
111
- Saving to .spec_durations.
112
- EOF
113
-
114
- new_durations.each do |file_path, duration|
115
- all_durations[file_path] = duration
116
- end
117
-
118
- persist_durations(all_durations)
119
- end
120
- elsif persist
121
- ::RSpec.configuration.output_stream.puts <<~EOF
122
-
123
- RSpec failed. Not saving to .spec_durations.
124
- EOF
125
- end
126
- end
127
-
128
- exit exit_code
129
- end
130
-
131
- private
132
-
133
- def self.load_recorded_durations
134
- durations = { }
135
-
136
- if File.exist?('.spec_durations')
137
- File.readlines('.spec_durations').each_with_index do |line, index|
138
- line = line.strip
139
-
140
- if !line.start_with?('#') && !line.empty?
141
- parts = line.split(',')
142
-
143
- unless parts.length == 2
144
- raise ShardError.new("fatal: invalid .spec_durations at line #{index + 1}")
145
- end
146
-
147
- file_path = parts[0].strip
148
-
149
- if file_path.empty?
150
- raise ShardError.new("fatal: invalid file path in .spec_durations at line #{index + 1}")
151
- end
152
-
153
- unless File.exist?(file_path)
154
- raise ShardError.new("fatal: file in .spec_durations not found at line #{index + 1}")
155
- end
156
-
157
- begin
158
- duration = Integer(parts[1])
159
- rescue ArgumentError => e
160
- raise ShardError.new("fatal: invalid .spec_durations at line #{index + 1}")
161
- end
162
-
163
- durations[file_path] = duration
164
- end
165
- end.compact
166
- end
167
-
168
- durations
169
- end
170
-
171
- def self.build_shards(total_shards, shard_num, durations)
172
- files = { }
173
-
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
- ::RSpec.configuration.error_stream.puts "warning: recorded duration not found for #{file_path}"
181
-
182
- # Assume 1000 milliseconds per example.
183
- files[file_path] += ::RSpec.world.example_count([example_group]) * 1000
184
- end
185
- end
186
-
187
- shards = (1..total_shards).map { { duration: 0, file_paths: [] } }
188
-
189
- # First sort by duration to ensure large files are distributed evenly.
190
- # Next, sort by path to ensure shards are generated deterministically.
191
- # Note that files is a map, sorting it turns it into an array of arrays.
192
- files = files.sort_by { |file_path, duration| [duration, file_path] }.reverse
193
- files.each do |file_path, duration|
194
- shards.sort_by! { |shard| shard[:duration] }
195
- shards[0][:file_paths] << file_path
196
- shards[0][:duration] += duration
197
- end
198
-
199
- shards.each { |shard| shard[:file_paths].sort! }
200
-
201
- shards
202
- end
203
-
204
- def self.persist_example_statuses(file_paths)
205
- return unless (path = ::RSpec.configuration.example_status_persistence_file_path)
206
-
207
- examples = ::RSpec.world.all_examples.select do |example|
208
- file_paths.include?(example.metadata[:file_path])
209
- end
210
- ::RSpec::Core::ExampleStatusPersister.persist(examples, path)
211
- rescue SystemCallError => e
212
- ::RSpec.configuration.error_stream.puts "warning: failed to write results to #{path}"
213
- end
214
-
215
- def self.pretty_duration(duration_millis)
216
- duration_seconds = (duration_millis / 1000.0).round
217
- minutes = duration_seconds / 60
218
- seconds = duration_seconds % 60
219
-
220
- minutes_str = "#{minutes} minute#{minutes == 1 ? '' : 's'}"
221
- seconds_str = "#{seconds} second#{seconds == 1 ? '' : 's'}"
222
-
223
- if minutes == 0
224
- seconds_str
225
- else
226
- "#{minutes_str}, #{seconds_str}"
227
- end
228
- end
229
-
230
- def self.print_shards(shards)
231
- ::RSpec.configuration.output_stream.puts
232
- shards.each_with_index do |shard, i|
233
- ::RSpec.configuration.output_stream.puts(
234
- "Shard #{i + 1} (Files: #{shard[:file_paths].size}, Duration: #{pretty_duration(shard[:duration])}):"
235
- )
236
- shard[:file_paths].each do |file_path|
237
- ::RSpec.configuration.output_stream.puts file_path
238
- end
239
- ::RSpec.configuration.output_stream.puts
240
- end
241
- end
242
-
243
- def self.persist_durations(durations)
244
- File.open(".spec_durations", "w+") do |file|
245
- file.puts <<~EOF
246
- # This file was created by rspec-sharder on #{Time.now.to_s}.
247
- # It is used to shard specs evenly. If test shards are uneven, run:
248
- #
249
- # bundle exec rspec-sharder --help
250
- EOF
251
- durations.sort_by { |file_path, duration| file_path }.each do |file_path, duration|
252
- file.puts "#{file_path},#{duration}"
253
- end
254
- end
255
- end
256
-
257
- def self.current_time_millis
258
- (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
259
- end
260
- end
261
- end