Class: SplitTestRb::CLI

Inherits:
Object
  • Object
show all
Defined in:
lib/split_test_rb.rb

Overview

Command-line interface

Constant Summary collapse

DEFAULT_OPTIONS =

Default option values for CLI

{
  node_index: 0,
  total_nodes: 1,
  debug: false,
  test_dir: 'spec',
  test_pattern: '**/*_spec.rb',
  split_by_example_threshold: nil
}.freeze

Class Method Summary collapse

Class Method Details

.add_missing_files_with_default_timing(timings, all_test_files) ⇒ Object

Adds test files missing from JSON results with default timing (1.0s)



218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/split_test_rb.rb', line 218

def self.add_missing_files_with_default_timing(timings, all_test_files)
  default_files = Set.new
  missing_files = all_test_files.keys - timings.keys

  return default_files if missing_files.empty?

  warn "Warning: Found #{missing_files.size} test files not in JSON, adding with default execution time"
  missing_files.each do |file|
    timings[file] = 1.0
    default_files.add(file)
  end

  default_files
end

.apply_example_splitting(file_timings, json_files, threshold) ⇒ Object

Splits heavy files (>= threshold) into individual examples



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/split_test_rb.rb', line 198

def self.apply_example_splitting(file_timings, json_files, threshold)
  heavy_files = file_timings.select { |_file, time| time >= threshold }
  return file_timings if heavy_files.empty?

  example_timings = JsonParser.parse_files_with_examples(json_files)

  # Start with light files (below threshold)
  timings = file_timings.reject { |file, _| heavy_files.key?(file) }

  # Add individual examples from heavy files
  heavy_files.each_key do |heavy_file|
    example_timings.each do |example_id, time|
      timings[example_id] = time if example_id.start_with?(heavy_file)
    end
  end

  timings
end

.build_option_parser(options) ⇒ Object

Builds and configures the OptionParser instance



263
264
265
266
267
268
# File 'lib/split_test_rb.rb', line 263

def self.build_option_parser(options)
  OptionParser.new do |opts|
    opts.banner = 'Usage: split-test-rb [options]'
    define_options(opts, options)
  end
end

.define_node_options(opts, options) ⇒ Object

Defines node distribution related CLI options



277
278
279
280
281
# File 'lib/split_test_rb.rb', line 277

def self.define_node_options(opts, options)
  opts.on('--node-index INDEX', Integer, 'Current node index (0-based)') { |v| options[:node_index] = v }
  opts.on('--node-total TOTAL', Integer, 'Total number of nodes') { |v| options[:total_nodes] = v }
  opts.on('--json-path PATH', 'Path to directory containing RSpec JSON reports') { |v| options[:json_path] = v }
end

.define_options(opts, options) ⇒ Object

Defines all CLI options on the given OptionParser



271
272
273
274
# File 'lib/split_test_rb.rb', line 271

def self.define_options(opts, options)
  define_node_options(opts, options)
  define_test_options(opts, options)
end

.define_test_options(opts, options) ⇒ Object

Defines test configuration and utility CLI options



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/split_test_rb.rb', line 284

def self.define_test_options(opts, options)
  opts.on('--test-dir DIR', 'Test directory (default: spec)') { |v| options[:test_dir] = v }
  opts.on('--test-pattern PATTERN', 'Test file pattern (default: **/*_spec.rb)') { |v| options[:test_pattern] = v }
  opts.on('--split-by-example-threshold SECONDS', Float,
          'Split files with execution time >= threshold into individual examples') do |v|
    options[:split_by_example_threshold] = v
  end
  opts.on('--debug', 'Show debug information') { options[:debug] = true }
  opts.on('-h', '--help', 'Show this help message') do
    puts opts
    exit
  end
  opts.on('-v', '--version', 'Show version') do
    puts "split-test-rb #{VERSION}"
    exit
  end
end

.exit_if_no_tests(timings) ⇒ Object



233
234
235
236
237
238
# File 'lib/split_test_rb.rb', line 233

def self.exit_if_no_tests(timings)
  return unless timings.empty?

  warn 'Warning: No test files found'
  exit 0
end

.find_all_spec_files(test_dir = 'spec', test_pattern = '**/*_spec.rb') ⇒ Object



302
303
304
305
306
307
308
309
310
311
# File 'lib/split_test_rb.rb', line 302

def self.find_all_spec_files(test_dir = 'spec', test_pattern = '**/*_spec.rb')
  # Find all test files in the specified directory with the given pattern
  glob_pattern = File.join(test_dir, test_pattern)
  test_files = Dir.glob(glob_pattern)
  # Normalize paths and assign equal execution time (1.0) to each file
  test_files.each_with_object({}) do |file, hash|
    normalized_path = JsonParser.normalize_path(file)
    hash[normalized_path] = 1.0
  end
end

.load_timings(options) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
# File 'lib/split_test_rb.rb', line 164

def self.load_timings(options)
  json_dir = options[:json_path]

  if File.directory?(json_dir)
    load_timings_from_json(json_dir, options)
  else
    warn "Warning: JSON directory not found: #{json_dir}, using all test files with equal execution time"
    timings = find_all_spec_files(options[:test_dir], options[:test_pattern])
    [timings, Set.new(timings.keys), []]
  end
end

.load_timings_from_json(json_dir, options) ⇒ Object



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/split_test_rb.rb', line 176

def self.load_timings_from_json(json_dir, options)
  json_files = Dir.glob(File.join(json_dir, '**', '*.json'))
  file_timings = JsonParser.parse_files(json_files)
  all_test_files = find_all_spec_files(options[:test_dir], options[:test_pattern])

  # Filter out files from JSON cache that don't match the test pattern
  file_timings.select! { |file, _| all_test_files.key?(file) }

  default_files = add_missing_files_with_default_timing(file_timings, all_test_files)

  # Apply example-level splitting if threshold is set
  threshold = options[:split_by_example_threshold]
  timings = if threshold
              apply_example_splitting(file_timings, json_files, threshold)
            else
              file_timings
            end

  [timings, default_files, json_files]
end

.output_node_files(nodes, node_index) ⇒ Object



240
241
242
243
# File 'lib/split_test_rb.rb', line 240

def self.output_node_files(nodes, node_index)
  node_files = nodes[node_index][:files]
  puts node_files.join("\n")
end

.parse_options(argv) ⇒ Object

Parses command-line arguments and returns options hash



256
257
258
259
260
# File 'lib/split_test_rb.rb', line 256

def self.parse_options(argv)
  options = DEFAULT_OPTIONS.dup
  build_option_parser(options).parse!(argv)
  options
end

.run(argv) ⇒ Object



144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/split_test_rb.rb', line 144

def self.run(argv)
  options = parse_options(argv)
  validate_options!(options)

  timings, default_files, json_files = load_timings(options)
  exit_if_no_tests(timings)

  nodes = Balancer.balance(timings, options[:total_nodes])
  DebugPrinter.print(nodes, timings, default_files, json_files) if options[:debug]

  output_node_files(nodes, options[:node_index])
end

.validate_options!(options) ⇒ Object



157
158
159
160
161
162
# File 'lib/split_test_rb.rb', line 157

def self.validate_options!(options)
  return if options[:json_path]

  warn 'Error: --json-path is required'
  exit 1
end