split-test-rb 1.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e7ce85d70006c8b2a102995f7cd447f650fd7b69e5c944c510b3a3964c48062e
4
+ data.tar.gz: 4da7116f406f33cc6a7e8289d3c492ed6c8606f914fdd2f0cfdcaca1b99535b1
5
+ SHA512:
6
+ metadata.gz: fe78061d59dabf16414d848d31a321e4584e751c92412e3c86e3544018e3e6660671b2699ddbdfe2e838352343df03df7e18fa0cc499387501c009063a3aefaf
7
+ data.tar.gz: 1507fe84941c2a77ce2a4f1af80ef21b23a1a41ca3c8f13a4b1b09a5163bfe48fffb5e62e2158d8545bee2bf156fb8e3cf0698c27b492d4dbef7ec37be154328
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 naofumi-fujii
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,165 @@
1
+ # split-test-rb
2
+
3
+ [![codecov](https://codecov.io/gh/naofumi-fujii/split-test-rb/branch/main/graph/badge.svg)](https://codecov.io/gh/naofumi-fujii/split-test-rb)
4
+
5
+ A simple Ruby CLI tool to balance RSpec tests across parallel CI nodes using RSpec JSON reports.
6
+
7
+ ## Overview
8
+
9
+ split-test-rb reads RSpec JSON test reports containing execution times and distributes test files across multiple nodes for parallel execution. It uses a greedy algorithm to ensure balanced distribution based on historical test execution times.
10
+
11
+ ## Installation
12
+
13
+ Since this gem is not yet published to RubyGems, you need to install it from GitHub.
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem 'split-test-rb', github: 'naofumi-fujii/split-test-rb'
19
+ ```
20
+
21
+ Then run:
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ ## GitHub Actions Example
28
+
29
+ First, add split-test-rb to your Gemfile:
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem 'split-test-rb', github: 'naofumi-fujii/split-test-rb'
34
+ ```
35
+
36
+ For a working example, see this project's own CI configuration:
37
+ - [.github/workflows/ci.yml](https://github.com/naofumi-fujii/split-test-rb/blob/main/.github/workflows/ci.yml)
38
+
39
+ ## Usage
40
+
41
+ ### Command Line Options
42
+
43
+ ```
44
+ split-test-rb [options]
45
+
46
+ Options:
47
+ --node-index INDEX Current node index (0-based)
48
+ --node-total TOTAL Total number of nodes
49
+ --json-path PATH Path to directory containing RSpec JSON reports (required)
50
+ --test-dir DIR Test directory (default: spec)
51
+ --test-pattern PATTERN Test file pattern (default: **/*_spec.rb)
52
+ --split-by-example-threshold SECONDS
53
+ Split files with execution time >= threshold into individual examples
54
+ --debug Show debug information
55
+ -h, --help Show help message
56
+ ```
57
+
58
+ ### Custom Test Directory and Pattern
59
+
60
+ By default, split-test-rb looks for test files in the `spec/` directory with the pattern `**/*_spec.rb`. You can customize this for projects with different test directory structures:
61
+
62
+ **Using Minitest with `test/` directory:**
63
+ ```bash
64
+ split-test-rb --json-path tmp/test-results \
65
+ --node-index $CI_NODE_INDEX \
66
+ --node-total $CI_NODE_TOTAL \
67
+ --test-dir test \
68
+ --test-pattern '**/*_test.rb'
69
+ ```
70
+
71
+ **Custom test directory structure:**
72
+ ```bash
73
+ split-test-rb --json-path tmp/test-results \
74
+ --node-index 0 \
75
+ --node-total 4 \
76
+ --test-dir tests \
77
+ --test-pattern 'unit/**/*.rb'
78
+ ```
79
+
80
+ The test directory and pattern options are useful for:
81
+ - Projects using Minitest (`test/` directory)
82
+ - Custom test directory structures
83
+ - Different naming conventions for test files
84
+ - Monorepos with multiple test suites
85
+
86
+ ### Example-Level Splitting for Heavy Files
87
+
88
+ When you have test files that take significantly longer than others, you can use `--split-by-example-threshold` to automatically split them into individual RSpec examples. This enables finer-grained load balancing across CI nodes.
89
+
90
+ ```bash
91
+ split-test-rb --json-path tmp/test-results \
92
+ --node-index $CI_NODE_INDEX \
93
+ --node-total $CI_NODE_TOTAL \
94
+ --split-by-example-threshold 10.0
95
+ ```
96
+
97
+ With this option:
98
+ - Files with execution time **below** the threshold are distributed as whole files (e.g., `spec/fast_spec.rb`)
99
+ - Files with execution time **at or above** the threshold are split into individual examples (e.g., `spec/slow_spec.rb[1:1]`, `spec/slow_spec.rb[1:2]`)
100
+
101
+ This is useful when:
102
+ - A single test file contains many slow examples that dominate a CI node's runtime
103
+ - You want to maximize parallelization without manually splitting large test files
104
+ - Some test files are bottlenecks that prevent even distribution
105
+
106
+ **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
+
108
+ ## How It Works
109
+
110
+ 1. **Parse RSpec JSON**: Extracts test file paths and execution times from the JSON report
111
+ 2. **Greedy Balancing**: Sorts files by execution time (descending) and assigns each file to the node with the lowest cumulative time
112
+ 3. **Output**: Prints the list of test files for the specified node
113
+
114
+ ## Fallback Behavior
115
+
116
+ split-test-rb provides intelligent fallback handling to ensure tests can run even without historical timing data:
117
+
118
+ ### When JSON file doesn't exist
119
+ If the specified JSON file is not found, the tool will:
120
+ - Display a warning: `Warning: JSON directory not found: <path>, using all test files with equal execution time`
121
+ - Find all test files matching the specified directory and pattern (default: `spec/**/*_spec.rb`)
122
+ - Assign equal execution time (1.0 seconds) to each file
123
+ - Distribute them evenly across nodes
124
+
125
+ This is useful for:
126
+ - First-time runs when no test history exists yet
127
+ - Local development environments
128
+ - New CI pipelines
129
+
130
+ ### When test files are missing from JSON
131
+ If new test files exist that aren't in the JSON report, the tool will:
132
+ - Display a warning: `Warning: Found N test files not in JSON, adding with default execution time`
133
+ - Add the missing files with default execution time (1.0 seconds)
134
+ - Include them in the distribution
135
+
136
+ This ensures newly added test files are always included in the test run.
137
+
138
+ ## RSpec JSON Format
139
+
140
+ The tool expects [RSpec JSON output format](https://rspec.info/features/3-13/rspec-core/formatters/json-formatter/) (generated with `--format json`):
141
+
142
+ ```json
143
+ {
144
+ "examples": [
145
+ {
146
+ "file_path": "./spec/models/user_spec.rb",
147
+ "run_time": 1.234
148
+ },
149
+ {
150
+ "file_path": "./spec/models/post_spec.rb",
151
+ "run_time": 0.567
152
+ }
153
+ ]
154
+ }
155
+ ```
156
+
157
+ To generate JSON reports with RSpec, use the built-in JSON formatter:
158
+
159
+ ```bash
160
+ bundle exec rspec --format json --out tmp/rspec-results/results.json
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT
data/bin/split-test-rb ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/split_test_rb'
4
+
5
+ SplitTestRb::CLI.run(ARGV)
@@ -0,0 +1,3 @@
1
+ module SplitTestRb
2
+ VERSION = '1.0.0'.freeze
3
+ end
@@ -0,0 +1,399 @@
1
+ require 'json'
2
+ require 'optparse'
3
+ require_relative 'split_test_rb/version'
4
+
5
+ module SplitTestRb
6
+ # Parses RSpec JSON result files and extracts test timing data
7
+ class JsonParser
8
+ # Parses RSpec JSON file and returns hash of {file_path => execution_time}
9
+ def self.parse(json_path)
10
+ content = File.read(json_path)
11
+ data = JSON.parse(content)
12
+ timings = {}
13
+
14
+ examples = data['examples'] || []
15
+ examples.each do |example|
16
+ file_path = extract_file_path(example)
17
+ run_time = example['run_time'].to_f
18
+
19
+ next unless file_path
20
+
21
+ # Normalize path to ensure consistent format (remove leading ./)
22
+ file_path = normalize_path(file_path)
23
+
24
+ # Aggregate timing for files (sum if multiple test cases from same file)
25
+ timings[file_path] ||= 0
26
+ timings[file_path] += run_time
27
+ end
28
+
29
+ timings
30
+ end
31
+
32
+ # Extracts file path from example, preferring id field over file_path
33
+ # This is important for shared examples where file_path points to the shared example file
34
+ # but id contains the actual spec file path (e.g., "./spec/features/entry_spec.rb[1:1:1]")
35
+ def self.extract_file_path(example)
36
+ if example['id']
37
+ # Extract file path from id (format: "./path/to/spec.rb[1:2:3]")
38
+ example['id'].split('[').first
39
+ else
40
+ example['file_path']
41
+ end
42
+ end
43
+
44
+ # Parses RSpec JSON file and returns hash of {example_id => execution_time}
45
+ # Example ID format: "spec/file.rb[1:1]"
46
+ def self.parse_with_examples(json_path)
47
+ content = File.read(json_path)
48
+ data = JSON.parse(content)
49
+ timings = {}
50
+
51
+ examples = data['examples'] || []
52
+ examples.each do |example|
53
+ next unless example['id']
54
+
55
+ example_id = normalize_path(example['id'])
56
+ run_time = example['run_time'].to_f
57
+
58
+ timings[example_id] = run_time
59
+ end
60
+
61
+ timings
62
+ end
63
+
64
+ # Parses multiple JSON files and returns hash of {example_id => execution_time}
65
+ def self.parse_files_with_examples(json_paths)
66
+ timings = {}
67
+
68
+ json_paths.each do |json_path|
69
+ next unless File.exist?(json_path)
70
+ next if File.empty?(json_path)
71
+
72
+ begin
73
+ example_timings = parse_with_examples(json_path)
74
+ example_timings.each do |example_id, time|
75
+ timings[example_id] = time
76
+ end
77
+ rescue JSON::ParserError => e
78
+ warn "Warning: Failed to parse #{json_path}: #{e.message}"
79
+ end
80
+ end
81
+
82
+ timings
83
+ end
84
+
85
+ # Parses all JSON files in a directory and merges results
86
+ def self.parse_directory(dir_path)
87
+ json_files = Dir.glob(File.join(dir_path, '**', '*.json'))
88
+ parse_files(json_files)
89
+ end
90
+
91
+ # Parses multiple JSON files and merges results
92
+ def self.parse_files(json_paths)
93
+ timings = {}
94
+
95
+ json_paths.each do |json_path|
96
+ next unless File.exist?(json_path)
97
+ next if File.empty?(json_path)
98
+
99
+ begin
100
+ file_timings = parse(json_path)
101
+ file_timings.each do |file, time|
102
+ timings[file] ||= 0
103
+ timings[file] += time
104
+ end
105
+ rescue JSON::ParserError => e
106
+ warn "Warning: Failed to parse #{json_path}: #{e.message}"
107
+ end
108
+ end
109
+
110
+ timings
111
+ end
112
+
113
+ # Normalizes file path by removing leading ./
114
+ def self.normalize_path(path)
115
+ path.sub(%r{^\./}, '')
116
+ end
117
+ end
118
+
119
+ # Balances test files across multiple nodes using greedy algorithm
120
+ class Balancer
121
+ # Distributes test files across nodes based on execution times
122
+ # Uses greedy algorithm: assign each file to the node with lowest cumulative time
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 }
126
+
127
+ # Initialize nodes with empty arrays and zero cumulative time
128
+ nodes = Array.new(total_nodes) { { files: [], total_time: 0 } }
129
+
130
+ # Assign each file to the node with lowest cumulative time
131
+ sorted_files.each do |file, time|
132
+ # Find node with minimum total time
133
+ min_node = nodes.min_by { |node| node[:total_time] }
134
+ min_node[:files] << file
135
+ min_node[:total_time] += time
136
+ end
137
+
138
+ nodes
139
+ end
140
+ end
141
+
142
+ # Command-line interface
143
+ class CLI
144
+ def self.run(argv)
145
+ options = parse_options(argv)
146
+ validate_options!(options)
147
+
148
+ timings, default_files, json_files = load_timings(options)
149
+ exit_if_no_tests(timings)
150
+
151
+ nodes = Balancer.balance(timings, options[:total_nodes])
152
+ DebugPrinter.print(nodes, timings, default_files, json_files) if options[:debug]
153
+
154
+ output_node_files(nodes, options[:node_index])
155
+ end
156
+
157
+ def self.validate_options!(options)
158
+ return if options[:json_path]
159
+
160
+ warn 'Error: --json-path is required'
161
+ exit 1
162
+ end
163
+
164
+ def self.load_timings(options)
165
+ json_dir = options[:json_path]
166
+
167
+ if File.directory?(json_dir)
168
+ load_timings_from_json(json_dir, options)
169
+ else
170
+ warn "Warning: JSON directory not found: #{json_dir}, using all test files with equal execution time"
171
+ timings = find_all_spec_files(options[:test_dir], options[:test_pattern])
172
+ [timings, Set.new(timings.keys), []]
173
+ end
174
+ end
175
+
176
+ def self.load_timings_from_json(json_dir, options)
177
+ json_files = Dir.glob(File.join(json_dir, '**', '*.json'))
178
+ file_timings = JsonParser.parse_files(json_files)
179
+ all_test_files = find_all_spec_files(options[:test_dir], options[:test_pattern])
180
+
181
+ # Filter out files from JSON cache that don't match the test pattern
182
+ file_timings.select! { |file, _| all_test_files.key?(file) }
183
+
184
+ default_files = add_missing_files_with_default_timing(file_timings, all_test_files)
185
+
186
+ # Apply example-level splitting if threshold is set
187
+ threshold = options[:split_by_example_threshold]
188
+ timings = if threshold
189
+ apply_example_splitting(file_timings, json_files, threshold)
190
+ else
191
+ file_timings
192
+ end
193
+
194
+ [timings, default_files, json_files]
195
+ end
196
+
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?
201
+
202
+ example_timings = JsonParser.parse_files_with_examples(json_files)
203
+
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
212
+ end
213
+
214
+ timings
215
+ end
216
+
217
+ # Adds test files missing from JSON results with default timing (1.0s)
218
+ def self.add_missing_files_with_default_timing(timings, all_test_files)
219
+ default_files = Set.new
220
+ missing_files = all_test_files.keys - timings.keys
221
+
222
+ return default_files if missing_files.empty?
223
+
224
+ warn "Warning: Found #{missing_files.size} test files not in JSON, adding with default execution time"
225
+ missing_files.each do |file|
226
+ timings[file] = 1.0
227
+ default_files.add(file)
228
+ end
229
+
230
+ default_files
231
+ end
232
+
233
+ def self.exit_if_no_tests(timings)
234
+ return unless timings.empty?
235
+
236
+ warn 'Warning: No test files found'
237
+ exit 0
238
+ end
239
+
240
+ def self.output_node_files(nodes, node_index)
241
+ node_files = nodes[node_index][:files]
242
+ puts node_files.join("\n")
243
+ end
244
+
245
+ # Default option values for CLI
246
+ DEFAULT_OPTIONS = {
247
+ node_index: 0,
248
+ total_nodes: 1,
249
+ debug: false,
250
+ test_dir: 'spec',
251
+ test_pattern: '**/*_spec.rb',
252
+ split_by_example_threshold: nil
253
+ }.freeze
254
+
255
+ # Parses command-line arguments and returns options hash
256
+ def self.parse_options(argv)
257
+ options = DEFAULT_OPTIONS.dup
258
+ build_option_parser(options).parse!(argv)
259
+ options
260
+ end
261
+
262
+ # Builds and configures the OptionParser instance
263
+ def self.build_option_parser(options)
264
+ OptionParser.new do |opts|
265
+ opts.banner = 'Usage: split-test-rb [options]'
266
+ define_options(opts, options)
267
+ end
268
+ end
269
+
270
+ # Defines all CLI options on the given OptionParser
271
+ def self.define_options(opts, options)
272
+ define_node_options(opts, options)
273
+ define_test_options(opts, options)
274
+ end
275
+
276
+ # Defines node distribution related CLI options
277
+ def self.define_node_options(opts, options)
278
+ opts.on('--node-index INDEX', Integer, 'Current node index (0-based)') { |v| options[:node_index] = v }
279
+ opts.on('--node-total TOTAL', Integer, 'Total number of nodes') { |v| options[:total_nodes] = v }
280
+ opts.on('--json-path PATH', 'Path to directory containing RSpec JSON reports') { |v| options[:json_path] = v }
281
+ end
282
+
283
+ # Defines test configuration and utility CLI options
284
+ def self.define_test_options(opts, options)
285
+ opts.on('--test-dir DIR', 'Test directory (default: spec)') { |v| options[:test_dir] = v }
286
+ 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
291
+ opts.on('--debug', 'Show debug information') { options[:debug] = true }
292
+ opts.on('-h', '--help', 'Show this help message') do
293
+ puts opts
294
+ exit
295
+ end
296
+ opts.on('-v', '--version', 'Show version') do
297
+ puts "split-test-rb #{VERSION}"
298
+ exit
299
+ end
300
+ end
301
+
302
+ def self.find_all_spec_files(test_dir = 'spec', test_pattern = '**/*_spec.rb')
303
+ # Find all test files in the specified directory with the given pattern
304
+ glob_pattern = File.join(test_dir, test_pattern)
305
+ test_files = Dir.glob(glob_pattern)
306
+ # Normalize paths and assign equal execution time (1.0) to each file
307
+ test_files.each_with_object({}) do |file, hash|
308
+ normalized_path = JsonParser.normalize_path(file)
309
+ hash[normalized_path] = 1.0
310
+ end
311
+ end
312
+ end
313
+
314
+ # Outputs debug information about test distribution
315
+ module DebugPrinter
316
+ # Shows distribution statistics, timing data sources, and per-node assignments
317
+ def self.print(nodes, timings, default_files, json_files)
318
+ total_files = timings.size
319
+ total_time = timings.values.sum.round(2)
320
+ files_from_xml = total_files - default_files.size
321
+ avg_time, variance, max_deviation = calculate_load_balance_stats(nodes, total_time)
322
+
323
+ warn '=== Test Balancing Debug Info ==='
324
+ warn ''
325
+ print_loaded_json_files(json_files, timings)
326
+ print_timing_data_source(files_from_xml, default_files.size, total_files, total_time)
327
+ print_load_balance_stats(avg_time, max_deviation)
328
+ print_node_distribution(nodes, variance, timings, default_files)
329
+ warn '===================================='
330
+ end
331
+
332
+ # Prints information about loaded JSON result files
333
+ def self.print_loaded_json_files(json_files, timings)
334
+ warn '## Loaded Test Result Files'
335
+ if json_files.empty?
336
+ warn ' (no JSON files loaded)'
337
+ else
338
+ json_files.each do |file|
339
+ warn " - #{file}"
340
+ end
341
+ warn " Total: #{json_files.size} JSON files, #{timings.size} test files extracted"
342
+ end
343
+ warn ''
344
+ end
345
+
346
+ # Calculates load balance statistics across nodes
347
+ def self.calculate_load_balance_stats(nodes, total_time)
348
+ avg_time = total_time / nodes.size
349
+ variance = nodes.map { |n| ((n[:total_time] - avg_time) / avg_time * 100).round(1) }
350
+ max_deviation = variance.map(&:abs).max
351
+ [avg_time, variance, max_deviation]
352
+ end
353
+
354
+ # Prints timing data source information
355
+ def self.print_timing_data_source(files_from_xml, default_files_count, total_files, total_time)
356
+ warn '## Timing Data Source (from past test execution results)'
357
+ warn " - Files with historical timing: #{files_from_xml} files"
358
+ warn " - Files with default timing (1.0s): #{default_files_count} files"
359
+ warn " - Total files: #{total_files} files"
360
+ warn " - Total estimated time: #{total_time}s"
361
+ warn ''
362
+ end
363
+
364
+ # Prints load balance statistics
365
+ def self.print_load_balance_stats(avg_time, max_deviation)
366
+ warn '## Load Balance'
367
+ warn " - Average time per node: #{avg_time.round(2)}s"
368
+ warn " - Max deviation from average: #{max_deviation}%"
369
+ warn ''
370
+ end
371
+
372
+ # Prints per-node distribution details
373
+ def self.print_node_distribution(nodes, variance, timings, default_files)
374
+ warn '## Per-Node Distribution'
375
+ nodes.each_with_index do |node, index|
376
+ print_node_info(node, index, variance[index], timings, default_files)
377
+ end
378
+ end
379
+
380
+ # Prints information for a single node
381
+ def self.print_node_info(node, index, deviation, timings, default_files)
382
+ deviation_str = deviation >= 0 ? "+#{deviation}%" : "#{deviation}%"
383
+ warn "Node #{index}: #{node[:files].size} files, #{node[:total_time].round(2)}s (#{deviation_str} from avg)"
384
+ node[:files].each do |file|
385
+ warn " - #{file} #{format_file_timing(file, timings, default_files)}"
386
+ end
387
+ warn ''
388
+ end
389
+
390
+ # Formats file timing information with labels
391
+ def self.format_file_timing(file, timings, default_files)
392
+ time = timings[file]
393
+ timing_str = "(#{time.round(2)}s"
394
+ timing_str += ', default - no historical data' if default_files.include?(file)
395
+ timing_str += ')'
396
+ timing_str
397
+ end
398
+ end
399
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: split-test-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Naofumi Fujii
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubocop
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.50'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.50'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop-rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.9'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.22'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.22'
69
+ description: A simple CLI tool to balance RSpec tests across parallel CI nodes using
70
+ RSpec JSON reports
71
+ email:
72
+ executables:
73
+ - split-test-rb
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - bin/split-test-rb
80
+ - lib/split_test_rb.rb
81
+ - lib/split_test_rb/version.rb
82
+ homepage: https://github.com/naofumi-fujii/split-test-rb
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ rubygems_mfa_required: 'true'
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.2.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.5.11
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: Split tests across multiple nodes based on timing data
106
+ test_files: []