rspec-sharder 0.0.1

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