rspec-sharder 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6d525960fabd4fd6d3c83ce963601ca0552bbcb15e11c212cdf872de2cd71a36
4
+ data.tar.gz: 5c7591851f241b1ab2b017ad81f3488bd5ac6dea21c1978df4e2cb963bd17bf2
5
+ SHA512:
6
+ metadata.gz: 1c65df78f7e5d973308808db3c48309c14d3e0abd1d7b5ccc37d1faa1d2f82b1ff98acba5a38f39947f47927cffaedf4dfe3f4b744b4cceb11959df2762b5bf7
7
+ data.tar.gz: db806df9aaa7f00cf7f62a2f583908a1d98864d2dd34b93a61a477c35716fa55f3f42c2cf9f22b9be49474e0d164f01776f2d1d8fbe4ce7ecbc05c9ebb3ab9dd
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ ruby '3.0.0'
4
+
5
+ source "https://rubygems.org"
6
+
7
+ gemspec
8
+
9
+ group :test do
10
+ gem 'rspec'
11
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,36 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ rspec-sharder (0.0.1)
5
+ rspec-core
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.4.4)
11
+ rspec (3.10.0)
12
+ rspec-core (~> 3.10.0)
13
+ rspec-expectations (~> 3.10.0)
14
+ rspec-mocks (~> 3.10.0)
15
+ rspec-core (3.10.1)
16
+ rspec-support (~> 3.10.0)
17
+ rspec-expectations (3.10.1)
18
+ diff-lcs (>= 1.2.0, < 2.0)
19
+ rspec-support (~> 3.10.0)
20
+ rspec-mocks (3.10.2)
21
+ diff-lcs (>= 1.2.0, < 2.0)
22
+ rspec-support (~> 3.10.0)
23
+ rspec-support (3.10.2)
24
+
25
+ PLATFORMS
26
+ x86_64-darwin-20
27
+
28
+ DEPENDENCIES
29
+ rspec
30
+ rspec-sharder!
31
+
32
+ RUBY VERSION
33
+ ruby 3.0.0p0
34
+
35
+ BUNDLED WITH
36
+ 2.2.21
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Nick Dower
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # RSpec Sharder
2
+
3
+ ```
4
+ Groups specs into shards, ensuring that each shard has a similar size, and runs
5
+ the specified shard.
6
+
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.
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:
16
+
17
+ bundle exec rspec-sharder --persist -- [<rspec-args...>]
18
+
19
+ Usage: bundle exec rspec-sharder [--total-shards <num> [--shard <num>]] [--persist] -- [<rspec-args...>]
20
+
21
+ Options:
22
+ -h, --help Print this message.
23
+ -t, --total-shards <num> The total number of shards. Defaults to 1.
24
+ -s, --shard <num> The shard to run. Defaults to 1.
25
+ -p, --persist Save durations to .spec_durations.
26
+ ```
data/bin/rspec-sharder ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
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)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Sharder
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,261 @@
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
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rspec-sharder/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rspec-sharder"
7
+ spec.version = RSpec::Sharder::VERSION
8
+ spec.authors = ["Nick Dower"]
9
+ spec.email = ["nicholasdower@gmail.com"]
10
+
11
+ spec.summary = "A utility which shards specs."
12
+ spec.description = "A utility which shards specs."
13
+ spec.homepage = "https://github.com/nicholasdower/rspec-sharder"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/nicholasdower/rspec-sharder"
19
+ spec.metadata["changelog_uri"] = "https://github.com/nicholasdower/rspec-sharder/releases"
20
+
21
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
23
+ end
24
+ spec.bindir = 'bin'
25
+ spec.executables << 'rspec-sharder'
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_dependency 'rspec-core'
29
+ end
@@ -0,0 +1,26 @@
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ if [ $# -ne 1 ]; then
6
+ echo "usage: $0 <version>" >&2
7
+ exit 1
8
+ fi
9
+
10
+ if [ -n "$(git status --porcelain)" ]; then
11
+ echo "error: stage or commit your changes." >&2
12
+ exit 1;
13
+ fi
14
+
15
+ NEW_VERSION=$1
16
+ CURRENT_VERSION=$(grep VERSION lib/rspec-sharder/version.rb | cut -d'"' -f 2)
17
+
18
+ echo "Updating from v$CURRENT_VERSION to v$NEW_VERSION. Press enter to continue."
19
+ read
20
+
21
+ sed -E -i '' "s/VERSION = \"[^\"]+\"/VERSION = \"$NEW_VERSION\"/g" lib/rspec-sharder/version.rb
22
+ gem build
23
+ gem push rspec-sharder-$NEW_VERSION.gem
24
+ bundle install
25
+ git commit -a -m "v$NEW_VERSION Release"
26
+ open "https://github.com/nicholasdower/rspec-sharder/releases/new?title=v$NEW_VERSION%20Release&tag=v$NEW_VERSION&target=$(git rev-parse HEAD)"
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-sharder
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Nick Dower
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-08-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec-core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: A utility which shards specs.
28
+ email:
29
+ - nicholasdower@gmail.com
30
+ executables:
31
+ - rspec-sharder
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - Gemfile
36
+ - Gemfile.lock
37
+ - LICENSE
38
+ - README.md
39
+ - bin/rspec-sharder
40
+ - lib/rspec-sharder.rb
41
+ - lib/rspec-sharder/version.rb
42
+ - rspec-sharder.gemspec
43
+ - scripts/release.sh
44
+ homepage: https://github.com/nicholasdower/rspec-sharder
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/nicholasdower/rspec-sharder
49
+ source_code_uri: https://github.com/nicholasdower/rspec-sharder
50
+ changelog_uri: https://github.com/nicholasdower/rspec-sharder/releases
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 2.4.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.2.3
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: A utility which shards specs.
70
+ test_files: []