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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cb166ceb6a788adcf47b71d1ef238cbe94ac18798c2a3a170a99dd44f9715e6
4
- data.tar.gz: 208cc97019fe0efc571d5f6b69f8b4f75ff6b93d89fb204c367de1a97b53fb08
3
+ metadata.gz: ffaee1d0d9211e912e20044c5fbf90b4d6c2ad643321d3c5dc94f2cbf5a8921f
4
+ data.tar.gz: bae21fcdfab3ff5d94e96a1bfa8d00e391ff5387b20e51570ee1bfe3f169ae83
5
5
  SHA512:
6
- metadata.gz: 734254624a19e1241eb9f08621947f6a1ea7ff292308c6ac87ab0a0a6b356abd2f93cfa5cb5c26bc6510cd49b3c42f208d7fe8c930abd1ec65354827003adf8f
7
- data.tar.gz: c50254eab36456759c70d091f67aeca40153b658e0d8533bbafa8245af89b718c1cd4bc9f8faaf0c1d98925cada0dd12a9447f79d65297fd4ef9a98453ceb08f
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
- 1. Update the version number in `lib/split_test_rb/version.rb`
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
 
@@ -1,3 +1,3 @@
1
1
  module SplitTestRb
2
- VERSION = '1.0.2'.freeze
2
+ VERSION = '1.0.4'.freeze
3
3
  end
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
- sorted_files = timings.sort_by { |_file, time| -time }
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
- apply_example_splitting(file_timings, json_files, threshold)
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
- heavy_files = file_timings.select { |_file, time| time >= threshold }
200
- return file_timings if heavy_files.empty?
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
- example_timings = JsonParser.parse_files_with_examples(json_files)
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
- # Start with light files (below threshold)
205
- timings = file_timings.reject { |file, _| heavy_files.key?(file) }
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
- timings
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.on('--split-by-example-threshold SECONDS', Float,
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.2
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-03-23 00:00:00.000000000 Z
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.22'
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.22'
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.2.0
95
+ version: 3.3.0
96
96
  required_rubygems_version: !ruby/object:Gem::Requirement
97
97
  requirements:
98
98
  - - ">="