split-test-rb 1.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cb166ceb6a788adcf47b71d1ef238cbe94ac18798c2a3a170a99dd44f9715e6
4
- data.tar.gz: 208cc97019fe0efc571d5f6b69f8b4f75ff6b93d89fb204c367de1a97b53fb08
3
+ metadata.gz: 0ada8a8a7b86b617e21c1e4cb0aced70a07c7c558d133eeaf52009bb0e72c8b3
4
+ data.tar.gz: bfa1c1f339c5b8506e4ca4b29847b033cfa81ef0595177306281faa308651657
5
5
  SHA512:
6
- metadata.gz: 734254624a19e1241eb9f08621947f6a1ea7ff292308c6ac87ab0a0a6b356abd2f93cfa5cb5c26bc6510cd49b3c42f208d7fe8c930abd1ec65354827003adf8f
7
- data.tar.gz: c50254eab36456759c70d091f67aeca40153b658e0d8533bbafa8245af89b718c1cd4bc9f8faaf0c1d98925cada0dd12a9447f79d65297fd4ef9a98453ceb08f
6
+ metadata.gz: bc8cbf6a51dd57ec475591a53a1a0fcd11f92c133f22e74541374ec4039824360d2a8497d05895fcb0547791087755f230e5429f7d952d681c78e294e068f299
7
+ data.tar.gz: ece0a80dcf20141ac7ebafdd82a8a8decd73ea72c3d326f36ee4f58248795d5bf57211268d19f669db1db09fd5082b426af230a09159747bef97014e54f06246
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.3'.freeze
3
3
  end
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
- apply_example_splitting(file_timings, json_files, threshold)
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
- heavy_files = file_timings.select { |_file, time| time >= threshold }
200
- return file_timings if heavy_files.empty?
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
- example_timings = JsonParser.parse_files_with_examples(json_files)
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
- # 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
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
- timings
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)
@@ -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.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
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.2
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-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: