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 +7 -0
- data/LICENSE +21 -0
- data/README.md +165 -0
- data/bin/split-test-rb +5 -0
- data/lib/split_test_rb/version.rb +3 -0
- data/lib/split_test_rb.rb +399 -0
- metadata +106 -0
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
|
+
[](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,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: []
|