split-test-rb 1.0.2 → 1.0.4
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 +4 -4
- data/README.md +31 -4
- data/lib/split_test_rb/version.rb +1 -1
- data/lib/split_test_rb.rb +86 -22
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ffaee1d0d9211e912e20044c5fbf90b4d6c2ad643321d3c5dc94f2cbf5a8921f
|
|
4
|
+
data.tar.gz: bae21fcdfab3ff5d94e96a1bfa8d00e391ff5387b20e51570ee1bfe3f169ae83
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56d1f3f3ddaa7e24be47904d499e01d4aac169f4fdfde9678a7c72644d2a5ac065d1bb990e44a1e579c6ff402f469fd9f2867eedbf92f68289b9b88c9deccd50
|
|
7
|
+
data.tar.gz: 365fbe88adab8a447848389ec8039521d7488a974079fb2ed91086f35d111ce8a74bd465a5b4835a631edd64650396800dcd8828250e0a4c6970b2d3257bd0d7
|
data/README.md
CHANGED
|
@@ -49,6 +49,7 @@ Options:
|
|
|
49
49
|
--test-pattern PATTERN Test file pattern (default: **/*_spec.rb)
|
|
50
50
|
--split-by-example-threshold SECONDS
|
|
51
51
|
Split files with execution time >= threshold into individual examples
|
|
52
|
+
--dry-run-json PATH RSpec JSON from `rspec --dry-run --format json`, used to list current examples of heavy files
|
|
52
53
|
--debug Show debug information
|
|
53
54
|
-h, --help Show help message
|
|
54
55
|
```
|
|
@@ -103,6 +104,29 @@ This is useful when:
|
|
|
103
104
|
|
|
104
105
|
**Note:** The JSON report must contain the `id` field for each example (RSpec's default JSON formatter includes this). The tool uses these IDs to generate the example-specific paths that RSpec can run.
|
|
105
106
|
|
|
107
|
+
#### Keeping heavy files up to date with `--dry-run-json`
|
|
108
|
+
|
|
109
|
+
By default, heavy files are assigned only by the example IDs found in the cached JSON reports. Examples that are not in the cache are not assigned to any node:
|
|
110
|
+
|
|
111
|
+
- Examples newly added to a heavy file
|
|
112
|
+
- Examples whose IDs shifted because other examples were inserted or moved (RSpec example IDs are position based, e.g. `[1:2:1]`)
|
|
113
|
+
|
|
114
|
+
Pass the output of `rspec --dry-run --format json` to use the examples that currently exist:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bundle exec rspec --dry-run --format json --out tmp/dry-run.json
|
|
118
|
+
split-test-rb --json-path tmp/test-results \
|
|
119
|
+
--node-index $CI_NODE_INDEX \
|
|
120
|
+
--node-total $CI_NODE_TOTAL \
|
|
121
|
+
--split-by-example-threshold 10.0 \
|
|
122
|
+
--dry-run-json tmp/dry-run.json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
With this option, for each heavy file:
|
|
126
|
+
- Examples in the dry-run JSON are assigned, using cached timings when available and the default timing (1.0s) otherwise
|
|
127
|
+
- Cached example IDs that no longer exist are ignored
|
|
128
|
+
- If the dry-run JSON has no examples for the file, the whole file is assigned
|
|
129
|
+
|
|
106
130
|
## How It Works
|
|
107
131
|
|
|
108
132
|
1. **Parse RSpec JSON**: Extracts test file paths and execution times from the JSON report
|
|
@@ -160,11 +184,14 @@ bundle exec rspec --format json --out tmp/rspec-results/results.json
|
|
|
160
184
|
|
|
161
185
|
## Release
|
|
162
186
|
|
|
163
|
-
To release a new version:
|
|
187
|
+
To release a new version, use the [Release workflow](.github/workflows/release.yml):
|
|
188
|
+
|
|
189
|
+
1. Go to the [Actions](https://github.com/naofumi-fujii/split-test-rb/actions/workflows/release.yml) tab
|
|
190
|
+
2. Click "Run workflow"
|
|
191
|
+
3. Select the version bump type (patch/minor/major)
|
|
192
|
+
4. Click "Run workflow"
|
|
164
193
|
|
|
165
|
-
|
|
166
|
-
2. Commit the change and push to `main`
|
|
167
|
-
3. The [Release workflow](.github/workflows/release.yml) automatically creates a git tag (`v*`) and publishes the gem to RubyGems via trusted publishing
|
|
194
|
+
The workflow will automatically update `lib/split_test_rb/version.rb`, commit it to `main`, create a git tag (`v*`), publish the gem to RubyGems via trusted publishing, and create a GitHub Release.
|
|
168
195
|
|
|
169
196
|
## License
|
|
170
197
|
|
data/lib/split_test_rb.rb
CHANGED
|
@@ -121,8 +121,10 @@ module SplitTestRb
|
|
|
121
121
|
# Distributes test files across nodes based on execution times
|
|
122
122
|
# Uses greedy algorithm: assign each file to the node with lowest cumulative time
|
|
123
123
|
def self.balance(timings, total_nodes)
|
|
124
|
-
# Sort files by execution time (descending) for better balance
|
|
125
|
-
|
|
124
|
+
# Sort files by execution time (descending) for better balance.
|
|
125
|
+
# Ties are broken by file name so that every node computes the same split
|
|
126
|
+
# even when the input order differs (e.g. a dry-run JSON listed in random order)
|
|
127
|
+
sorted_files = timings.sort_by { |file, time| [-time, file] }
|
|
126
128
|
|
|
127
129
|
# Initialize nodes with empty arrays and zero cumulative time
|
|
128
130
|
nodes = Array.new(total_nodes) { { files: [], total_time: 0 } }
|
|
@@ -139,6 +141,58 @@ module SplitTestRb
|
|
|
139
141
|
end
|
|
140
142
|
end
|
|
141
143
|
|
|
144
|
+
# Splits heavy test files into individual examples for finer-grained balancing
|
|
145
|
+
# Uses current_example_ids (from `rspec --dry-run --format json`) as the source of truth when given,
|
|
146
|
+
# so that examples missing from the cached JSON (new or shifted IDs) are still assigned
|
|
147
|
+
class ExampleSplitter
|
|
148
|
+
DEFAULT_TIMING = 1.0
|
|
149
|
+
|
|
150
|
+
def initialize(json_files, current_example_ids: nil, default_files: nil)
|
|
151
|
+
@example_timings = JsonParser.parse_files_with_examples(json_files)
|
|
152
|
+
@current_example_ids = current_example_ids
|
|
153
|
+
@default_files = default_files
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns timings where files >= threshold are replaced by their examples
|
|
157
|
+
def split(file_timings, threshold)
|
|
158
|
+
heavy_files = file_timings.select { |_file, time| time >= threshold }
|
|
159
|
+
return file_timings if heavy_files.empty?
|
|
160
|
+
|
|
161
|
+
# Start with light files (below threshold)
|
|
162
|
+
timings = file_timings.reject { |file, _| heavy_files.key?(file) }
|
|
163
|
+
heavy_files.each { |heavy_file, file_time| timings.merge!(examples_for(heavy_file, file_time)) }
|
|
164
|
+
timings
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def examples_for(heavy_file, file_time)
|
|
170
|
+
return cached_examples_for(heavy_file) unless @current_example_ids
|
|
171
|
+
|
|
172
|
+
ids = @current_example_ids.select { |example_id| example_of?(example_id, heavy_file) }
|
|
173
|
+
# Assign the whole file when the dry-run JSON has no examples for it, so no tests are dropped
|
|
174
|
+
return { heavy_file => file_time } if ids.empty?
|
|
175
|
+
|
|
176
|
+
ids.to_h { |example_id| [example_id, timing_for(example_id)] }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def cached_examples_for(heavy_file)
|
|
180
|
+
@example_timings.select { |example_id, _| example_of?(example_id, heavy_file) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def timing_for(example_id)
|
|
184
|
+
return @example_timings[example_id] if @example_timings.key?(example_id)
|
|
185
|
+
|
|
186
|
+
@default_files&.add(example_id)
|
|
187
|
+
DEFAULT_TIMING
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns true if example_id (e.g. "spec/a_spec.rb[1:1]") belongs to file_path
|
|
191
|
+
def example_of?(example_id, file_path)
|
|
192
|
+
example_id.start_with?("#{file_path}[")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
142
196
|
# Command-line interface
|
|
143
197
|
class CLI
|
|
144
198
|
def self.run(argv)
|
|
@@ -186,7 +240,9 @@ module SplitTestRb
|
|
|
186
240
|
# Apply example-level splitting if threshold is set
|
|
187
241
|
threshold = options[:split_by_example_threshold]
|
|
188
242
|
timings = if threshold
|
|
189
|
-
|
|
243
|
+
current_example_ids = load_current_example_ids(options[:dry_run_json])
|
|
244
|
+
apply_example_splitting(file_timings, json_files, threshold,
|
|
245
|
+
current_example_ids: current_example_ids, default_files: default_files)
|
|
190
246
|
else
|
|
191
247
|
file_timings
|
|
192
248
|
end
|
|
@@ -194,24 +250,22 @@ module SplitTestRb
|
|
|
194
250
|
[timings, default_files, json_files]
|
|
195
251
|
end
|
|
196
252
|
|
|
197
|
-
# Splits heavy files (>= threshold) into individual examples
|
|
198
|
-
def self.apply_example_splitting(file_timings, json_files, threshold)
|
|
199
|
-
|
|
200
|
-
|
|
253
|
+
# Splits heavy files (>= threshold) into individual examples (delegates to ExampleSplitter)
|
|
254
|
+
def self.apply_example_splitting(file_timings, json_files, threshold, current_example_ids: nil, default_files: nil)
|
|
255
|
+
ExampleSplitter.new(json_files, current_example_ids: current_example_ids, default_files: default_files)
|
|
256
|
+
.split(file_timings, threshold)
|
|
257
|
+
end
|
|
201
258
|
|
|
202
|
-
|
|
259
|
+
# Loads example IDs from an RSpec dry-run JSON report, or returns nil when no path is given
|
|
260
|
+
def self.load_current_example_ids(dry_run_json)
|
|
261
|
+
return nil unless dry_run_json
|
|
203
262
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
# Add individual examples from heavy files
|
|
208
|
-
heavy_files.each_key do |heavy_file|
|
|
209
|
-
example_timings.each do |example_id, time|
|
|
210
|
-
timings[example_id] = time if example_id.start_with?(heavy_file)
|
|
211
|
-
end
|
|
263
|
+
unless File.exist?(dry_run_json)
|
|
264
|
+
warn "Error: dry-run JSON not found: #{dry_run_json}"
|
|
265
|
+
exit 1
|
|
212
266
|
end
|
|
213
267
|
|
|
214
|
-
|
|
268
|
+
JsonParser.parse_with_examples(dry_run_json).keys
|
|
215
269
|
end
|
|
216
270
|
|
|
217
271
|
# Adds test files missing from JSON results with default timing (1.0s)
|
|
@@ -249,7 +303,8 @@ module SplitTestRb
|
|
|
249
303
|
debug: false,
|
|
250
304
|
test_dir: 'spec',
|
|
251
305
|
test_pattern: '**/*_spec.rb',
|
|
252
|
-
split_by_example_threshold: nil
|
|
306
|
+
split_by_example_threshold: nil,
|
|
307
|
+
dry_run_json: nil
|
|
253
308
|
}.freeze
|
|
254
309
|
|
|
255
310
|
# Parses command-line arguments and returns options hash
|
|
@@ -284,10 +339,7 @@ module SplitTestRb
|
|
|
284
339
|
def self.define_test_options(opts, options)
|
|
285
340
|
opts.on('--test-dir DIR', 'Test directory (default: spec)') { |v| options[:test_dir] = v }
|
|
286
341
|
opts.on('--test-pattern PATTERN', 'Test file pattern (default: **/*_spec.rb)') { |v| options[:test_pattern] = v }
|
|
287
|
-
opts
|
|
288
|
-
'Split files with execution time >= threshold into individual examples') do |v|
|
|
289
|
-
options[:split_by_example_threshold] = v
|
|
290
|
-
end
|
|
342
|
+
define_split_options(opts, options)
|
|
291
343
|
opts.on('--debug', 'Show debug information') { options[:debug] = true }
|
|
292
344
|
opts.on('-h', '--help', 'Show this help message') do
|
|
293
345
|
puts opts
|
|
@@ -299,6 +351,18 @@ module SplitTestRb
|
|
|
299
351
|
end
|
|
300
352
|
end
|
|
301
353
|
|
|
354
|
+
# Defines example-level splitting related CLI options
|
|
355
|
+
def self.define_split_options(opts, options)
|
|
356
|
+
opts.on('--split-by-example-threshold SECONDS', Float,
|
|
357
|
+
'Split files with execution time >= threshold into individual examples') do |v|
|
|
358
|
+
options[:split_by_example_threshold] = v
|
|
359
|
+
end
|
|
360
|
+
opts.on('--dry-run-json PATH',
|
|
361
|
+
'RSpec JSON from `rspec --dry-run --format json`, used to list current examples of heavy files') do |v|
|
|
362
|
+
options[:dry_run_json] = v
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
302
366
|
def self.find_all_spec_files(test_dir = 'spec', test_pattern = '**/*_spec.rb')
|
|
303
367
|
# Find all test files in the specified directory with the given pattern
|
|
304
368
|
glob_pattern = File.join(test_dir, test_pattern)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: split-test-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Naofumi Fujii
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -58,14 +58,14 @@ dependencies:
|
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '0
|
|
61
|
+
version: '1.0'
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '0
|
|
68
|
+
version: '1.0'
|
|
69
69
|
description: A simple CLI tool to balance RSpec tests across parallel CI nodes using
|
|
70
70
|
RSpec JSON reports
|
|
71
71
|
email:
|
|
@@ -92,7 +92,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
92
92
|
requirements:
|
|
93
93
|
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version: 3.
|
|
95
|
+
version: 3.3.0
|
|
96
96
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
97
|
requirements:
|
|
98
98
|
- - ">="
|