split-test-rb 1.0.1 → 1.0.3
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 +37 -4
- data/lib/split_test_rb/version.rb +1 -1
- data/lib/split_test_rb.rb +83 -21
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ada8a8a7b86b617e21c1e4cb0aced70a07c7c558d133eeaf52009bb0e72c8b3
|
|
4
|
+
data.tar.gz: bfa1c1f339c5b8506e4ca4b29847b033cfa81ef0595177306281faa308651657
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc8cbf6a51dd57ec475591a53a1a0fcd11f92c133f22e74541374ec4039824360d2a8497d05895fcb0547791087755f230e5429f7d952d681c78e294e068f299
|
|
7
|
+
data.tar.gz: ece0a80dcf20141ac7ebafdd82a8a8decd73ea72c3d326f36ee4f58248795d5bf57211268d19f669db1db09fd5082b426af230a09159747bef97014e54f06246
|
data/README.md
CHANGED
|
@@ -10,12 +10,10 @@ split-test-rb reads RSpec JSON test reports containing execution times and distr
|
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
|
-
Since this gem is not yet published to RubyGems, you need to install it from GitHub.
|
|
14
|
-
|
|
15
13
|
Add to your Gemfile:
|
|
16
14
|
|
|
17
15
|
```ruby
|
|
18
|
-
gem 'split-test-rb'
|
|
16
|
+
gem 'split-test-rb'
|
|
19
17
|
```
|
|
20
18
|
|
|
21
19
|
Then run:
|
|
@@ -30,7 +28,7 @@ First, add split-test-rb to your Gemfile:
|
|
|
30
28
|
|
|
31
29
|
```ruby
|
|
32
30
|
# Gemfile
|
|
33
|
-
gem 'split-test-rb'
|
|
31
|
+
gem 'split-test-rb'
|
|
34
32
|
```
|
|
35
33
|
|
|
36
34
|
For a working example, see this project's own CI configuration:
|
|
@@ -51,6 +49,7 @@ Options:
|
|
|
51
49
|
--test-pattern PATTERN Test file pattern (default: **/*_spec.rb)
|
|
52
50
|
--split-by-example-threshold SECONDS
|
|
53
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
|
|
54
53
|
--debug Show debug information
|
|
55
54
|
-h, --help Show help message
|
|
56
55
|
```
|
|
@@ -105,6 +104,29 @@ This is useful when:
|
|
|
105
104
|
|
|
106
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.
|
|
107
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
|
+
|
|
108
130
|
## How It Works
|
|
109
131
|
|
|
110
132
|
1. **Parse RSpec JSON**: Extracts test file paths and execution times from the JSON report
|
|
@@ -160,6 +182,17 @@ To generate JSON reports with RSpec, use the built-in JSON formatter:
|
|
|
160
182
|
bundle exec rspec --format json --out tmp/rspec-results/results.json
|
|
161
183
|
```
|
|
162
184
|
|
|
185
|
+
## Release
|
|
186
|
+
|
|
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"
|
|
193
|
+
|
|
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.
|
|
195
|
+
|
|
163
196
|
## License
|
|
164
197
|
|
|
165
198
|
MIT
|
data/lib/split_test_rb.rb
CHANGED
|
@@ -139,6 +139,58 @@ module SplitTestRb
|
|
|
139
139
|
end
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
# Splits heavy test files into individual examples for finer-grained balancing
|
|
143
|
+
# Uses current_example_ids (from `rspec --dry-run --format json`) as the source of truth when given,
|
|
144
|
+
# so that examples missing from the cached JSON (new or shifted IDs) are still assigned
|
|
145
|
+
class ExampleSplitter
|
|
146
|
+
DEFAULT_TIMING = 1.0
|
|
147
|
+
|
|
148
|
+
def initialize(json_files, current_example_ids: nil, default_files: nil)
|
|
149
|
+
@example_timings = JsonParser.parse_files_with_examples(json_files)
|
|
150
|
+
@current_example_ids = current_example_ids
|
|
151
|
+
@default_files = default_files
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns timings where files >= threshold are replaced by their examples
|
|
155
|
+
def split(file_timings, threshold)
|
|
156
|
+
heavy_files = file_timings.select { |_file, time| time >= threshold }
|
|
157
|
+
return file_timings if heavy_files.empty?
|
|
158
|
+
|
|
159
|
+
# Start with light files (below threshold)
|
|
160
|
+
timings = file_timings.reject { |file, _| heavy_files.key?(file) }
|
|
161
|
+
heavy_files.each { |heavy_file, file_time| timings.merge!(examples_for(heavy_file, file_time)) }
|
|
162
|
+
timings
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def examples_for(heavy_file, file_time)
|
|
168
|
+
return cached_examples_for(heavy_file) unless @current_example_ids
|
|
169
|
+
|
|
170
|
+
ids = @current_example_ids.select { |example_id| example_of?(example_id, heavy_file) }
|
|
171
|
+
# Assign the whole file when the dry-run JSON has no examples for it, so no tests are dropped
|
|
172
|
+
return { heavy_file => file_time } if ids.empty?
|
|
173
|
+
|
|
174
|
+
ids.to_h { |example_id| [example_id, timing_for(example_id)] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cached_examples_for(heavy_file)
|
|
178
|
+
@example_timings.select { |example_id, _| example_of?(example_id, heavy_file) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def timing_for(example_id)
|
|
182
|
+
return @example_timings[example_id] if @example_timings.key?(example_id)
|
|
183
|
+
|
|
184
|
+
@default_files&.add(example_id)
|
|
185
|
+
DEFAULT_TIMING
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Returns true if example_id (e.g. "spec/a_spec.rb[1:1]") belongs to file_path
|
|
189
|
+
def example_of?(example_id, file_path)
|
|
190
|
+
example_id.start_with?("#{file_path}[")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
142
194
|
# Command-line interface
|
|
143
195
|
class CLI
|
|
144
196
|
def self.run(argv)
|
|
@@ -186,7 +238,9 @@ module SplitTestRb
|
|
|
186
238
|
# Apply example-level splitting if threshold is set
|
|
187
239
|
threshold = options[:split_by_example_threshold]
|
|
188
240
|
timings = if threshold
|
|
189
|
-
|
|
241
|
+
current_example_ids = load_current_example_ids(options[:dry_run_json])
|
|
242
|
+
apply_example_splitting(file_timings, json_files, threshold,
|
|
243
|
+
current_example_ids: current_example_ids, default_files: default_files)
|
|
190
244
|
else
|
|
191
245
|
file_timings
|
|
192
246
|
end
|
|
@@ -194,24 +248,22 @@ module SplitTestRb
|
|
|
194
248
|
[timings, default_files, json_files]
|
|
195
249
|
end
|
|
196
250
|
|
|
197
|
-
# Splits heavy files (>= threshold) into individual examples
|
|
198
|
-
def self.apply_example_splitting(file_timings, json_files, threshold)
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
# Splits heavy files (>= threshold) into individual examples (delegates to ExampleSplitter)
|
|
252
|
+
def self.apply_example_splitting(file_timings, json_files, threshold, current_example_ids: nil, default_files: nil)
|
|
253
|
+
ExampleSplitter.new(json_files, current_example_ids: current_example_ids, default_files: default_files)
|
|
254
|
+
.split(file_timings, threshold)
|
|
255
|
+
end
|
|
201
256
|
|
|
202
|
-
|
|
257
|
+
# Loads example IDs from an RSpec dry-run JSON report, or returns nil when no path is given
|
|
258
|
+
def self.load_current_example_ids(dry_run_json)
|
|
259
|
+
return nil unless dry_run_json
|
|
203
260
|
|
|
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
|
|
261
|
+
unless File.exist?(dry_run_json)
|
|
262
|
+
warn "Error: dry-run JSON not found: #{dry_run_json}"
|
|
263
|
+
exit 1
|
|
212
264
|
end
|
|
213
265
|
|
|
214
|
-
|
|
266
|
+
JsonParser.parse_with_examples(dry_run_json).keys
|
|
215
267
|
end
|
|
216
268
|
|
|
217
269
|
# Adds test files missing from JSON results with default timing (1.0s)
|
|
@@ -221,7 +273,7 @@ module SplitTestRb
|
|
|
221
273
|
|
|
222
274
|
return default_files if missing_files.empty?
|
|
223
275
|
|
|
224
|
-
warn "Warning:
|
|
276
|
+
warn "Warning: #{missing_files.size} test files not found in JSON, adding with default execution time"
|
|
225
277
|
missing_files.each do |file|
|
|
226
278
|
timings[file] = 1.0
|
|
227
279
|
default_files.add(file)
|
|
@@ -249,7 +301,8 @@ module SplitTestRb
|
|
|
249
301
|
debug: false,
|
|
250
302
|
test_dir: 'spec',
|
|
251
303
|
test_pattern: '**/*_spec.rb',
|
|
252
|
-
split_by_example_threshold: nil
|
|
304
|
+
split_by_example_threshold: nil,
|
|
305
|
+
dry_run_json: nil
|
|
253
306
|
}.freeze
|
|
254
307
|
|
|
255
308
|
# Parses command-line arguments and returns options hash
|
|
@@ -284,10 +337,7 @@ module SplitTestRb
|
|
|
284
337
|
def self.define_test_options(opts, options)
|
|
285
338
|
opts.on('--test-dir DIR', 'Test directory (default: spec)') { |v| options[:test_dir] = v }
|
|
286
339
|
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
|
|
340
|
+
define_split_options(opts, options)
|
|
291
341
|
opts.on('--debug', 'Show debug information') { options[:debug] = true }
|
|
292
342
|
opts.on('-h', '--help', 'Show this help message') do
|
|
293
343
|
puts opts
|
|
@@ -299,6 +349,18 @@ module SplitTestRb
|
|
|
299
349
|
end
|
|
300
350
|
end
|
|
301
351
|
|
|
352
|
+
# Defines example-level splitting related CLI options
|
|
353
|
+
def self.define_split_options(opts, options)
|
|
354
|
+
opts.on('--split-by-example-threshold SECONDS', Float,
|
|
355
|
+
'Split files with execution time >= threshold into individual examples') do |v|
|
|
356
|
+
options[:split_by_example_threshold] = v
|
|
357
|
+
end
|
|
358
|
+
opts.on('--dry-run-json PATH',
|
|
359
|
+
'RSpec JSON from `rspec --dry-run --format json`, used to list current examples of heavy files') do |v|
|
|
360
|
+
options[:dry_run_json] = v
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
302
364
|
def self.find_all_spec_files(test_dir = 'spec', test_pattern = '**/*_spec.rb')
|
|
303
365
|
# Find all test files in the specified directory with the given pattern
|
|
304
366
|
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.3
|
|
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:
|