xcknife 0.6.6 → 0.13.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.
@@ -1,15 +1,15 @@
1
- {"bundleName":"iPadTestTarget.xctest","deviceName":"iPad Air","event":"begin-ocunit","targetName":"iPadTestTarget","testType":"application-test","sdkName":"iphonesimulator9.2"}
1
+ {"bundleName":"iPadTestTarget.xctest","deviceName":"iPad Air","event":"begin-ocunit","targetName":"iPadTestTarget","testType":"application-test","sdkName":"iphonesimulator13.6"}
2
2
  {"test":"1","className":"iPadTestClassFour","event":"end-test","totalDuration":"0"}
3
3
  {"test":"1","className":"iPadTestClassOne","event":"end-test","totalDuration":"0"}
4
4
  {"test":"1","className":"iPadTestClassThree","event":"end-test","totalDuration":"0"}
5
5
  {"test":"1","className":"iPadTestClassTwo","event":"end-test","totalDuration":"0"}
6
- {"bundleName":"iPhoneTestTarget.xctest","deviceName":"iPad Air","event":"begin-ocunit","targetName":"iPhoneTestTarget","testType":"application-test","sdkName":"iphonesimulator9.2"}
6
+ {"bundleName":"iPhoneTestTarget.xctest","deviceName":"iPad Air","event":"begin-ocunit","targetName":"iPhoneTestTarget","testType":"application-test","sdkName":"iphonesimulator13.6"}
7
7
  {"test":"1","className":"iPhoneTestClassAlpha","event":"end-test","totalDuration":"0"}
8
8
  {"test":"1","className":"iPhoneTestClassBeta","event":"end-test","totalDuration":"0"}
9
9
  {"test":"1","className":"iPhoneTestClassDelta","event":"end-test","totalDuration":"0"}
10
10
  {"test":"1","className":"iPhoneTestClassGama","event":"end-test","totalDuration":"0"}
11
11
  {"test":"1","className":"iPhoneTestClassOmega","event":"end-test","totalDuration":"0"}
12
- {"bundleName":"CommonTestTarget.xctest","deviceName":"iPad Air","event":"begin-ocunit","targetName":"CommonTestTarget","testType":"logic-test","sdkName":"iphonesimulator9.2"}
12
+ {"bundleName":"CommonTestTarget.xctest","deviceName":"iPad Air","event":"begin-ocunit","targetName":"CommonTestTarget","testType":"logic-test","sdkName":"iphonesimulator13.6"}
13
13
  {"test":"1","className":"CommonTestClass","event":"end-test","totalDuration":"0"}
14
14
  {"test":"1","className":"CommonTestClass","event":"end-test","totalDuration":"0"}
15
15
  {"test":"1","className":"CommonTestClass","event":"end-test","totalDuration":"0"}
data/lib/xcknife.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'xcknife/events_analyzer'
2
4
  require 'xcknife/stream_parser'
3
5
  require 'xcknife/xctool_cmd_helper'
@@ -7,5 +9,5 @@ require 'xcknife/exceptions'
7
9
  require 'xcknife/xcscheme_analyzer'
8
10
 
9
11
  module XCKnife
10
- VERSION = '0.6.6'
12
+ VERSION = '0.13.0'
11
13
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'xcknife/json_stream_parser_helper'
2
4
  require 'set'
3
5
 
@@ -8,6 +10,7 @@ module XCKnife
8
10
 
9
11
  def self.for(events, relevant_partitions)
10
12
  return NullEventsAnalyzer.new if events.nil?
13
+
11
14
  new(events, relevant_partitions)
12
15
  end
13
16
 
@@ -17,19 +20,21 @@ module XCKnife
17
20
  @target_class_map = analyze_events(events)
18
21
  end
19
22
 
20
- def has_test_target?(target)
21
- target_class_map.has_key?(target)
23
+ def test_target?(target)
24
+ target_class_map.key?(target)
22
25
  end
23
26
 
24
- def has_test_class?(target, clazz)
25
- has_test_target?(target) and target_class_map[target].include?(clazz)
27
+ def test_class?(target, clazz)
28
+ test_target?(target) and target_class_map[target].include?(clazz)
26
29
  end
27
30
 
28
31
  private
32
+
29
33
  def analyze_events(events)
30
34
  ret = Hash.new { |h, key| h[key] = Set.new }
31
35
  each_test_event(events) do |target_name, result|
32
36
  next unless @relevant_partitions.include?(target_name)
37
+
33
38
  @total_tests += 1
34
39
  ret[target_name] << result.className
35
40
  end
@@ -40,11 +45,11 @@ module XCKnife
40
45
  # Null object for EventsAnalyzer
41
46
  # @ref https://en.wikipedia.org/wiki/Null_Object_pattern
42
47
  class NullEventsAnalyzer
43
- def has_test_target?(target)
48
+ def test_target?(_target)
44
49
  true
45
50
  end
46
51
 
47
- def has_test_class?(target, clazz)
52
+ def test_class?(_target, _clazz)
48
53
  true
49
54
  end
50
55
 
@@ -1,6 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module XCKnife
2
4
  # Base error class for xcknife
3
- XCKnifeError = Class.new(StandardError)
5
+ XCKnifeError = Class.new(RuntimeError)
6
+
7
+ TestDumpError = Class.new(XCKnifeError)
4
8
 
5
9
  StreamParsingError = Class.new(XCKnifeError)
6
- end
10
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module XCKnife
2
4
  module JsonStreamParserHelper
3
5
  extend self
@@ -6,18 +8,16 @@ module XCKnife
6
8
  def each_test_event(events, &block)
7
9
  current_target = nil
8
10
  events.each do |result|
9
- current_target = result.targetName if result.event == "begin-ocunit"
10
- if result.test and result.event == "end-test"
11
- raise XCKnife::StreamParsingError, "No test target defined" if current_target.nil?
12
- block.call(current_target, normalize_result(result))
13
- end
11
+ current_target = result.targetName if result.event == 'begin-ocunit'
12
+ next unless result.test && (result.event == 'end-test')
13
+ raise XCKnife::StreamParsingError, 'No test target defined' if current_target.nil?
14
+
15
+ block.call(current_target, normalize_result(result))
14
16
  end
15
17
  end
16
18
 
17
19
  def normalize_result(result)
18
- if result.totalDuration.is_a?(String)
19
- result.totalDuration = result.totalDuration.to_f
20
- end
20
+ result.totalDuration = result.totalDuration.to_f if result.totalDuration.is_a?(String)
21
21
  result
22
22
  end
23
23
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'optparse'
2
4
 
3
5
  module XCKnife
@@ -33,11 +35,11 @@ module XCKnife
33
35
  end
34
36
 
35
37
  private
38
+
36
39
  def gen_abbreviated_output(result)
37
40
  result.test_maps.map { |partition_set| only_arguments_for_a_partition_set(partition_set, output_type) }
38
41
  end
39
42
 
40
-
41
43
  def output_type
42
44
  @xcodebuild_output ? :xcodebuild : :xctool
43
45
  end
@@ -47,7 +49,7 @@ module XCKnife
47
49
  metadata: {
48
50
  worker_count: @worker_count,
49
51
  partition_set_count: result.test_maps.size,
50
- total_time_in_ms: result.total_test_time,
52
+ total_time_in_ms: result.total_test_time
51
53
  }.merge(result.stats.to_h),
52
54
  partition_set_data: partition_sets_data(result)
53
55
  }
@@ -81,18 +83,15 @@ module XCKnife
81
83
  def write_output(data)
82
84
  json = JSON.pretty_generate(data)
83
85
  return puts json if @output_file_name.nil?
84
- File.open(@output_file_name, "w") { |f| f.puts(json) }
86
+
87
+ File.open(@output_file_name, 'w') { |f| f.puts(json) }
85
88
  puts "Wrote file to: #{@output_file_name}"
86
89
  end
87
90
 
88
91
  def parse_arguments(args)
89
92
  positional_arguments = parse_options(args)
90
- if positional_arguments.size < required_arguments.size
91
- warn_and_exit("You must specify *all* required arguments: #{required_arguments.join(", ")}")
92
- end
93
- if @partitions.empty?
94
- warn_and_exit("At least one target partition set must be provided with -p flag")
95
- end
93
+ warn_and_exit("You must specify *all* required arguments: #{required_arguments.join(', ')}") if positional_arguments.size < required_arguments.size
94
+ warn_and_exit('At least one target partition set must be provided with -p flag') if @partitions.empty?
96
95
  worker_count, @historical_timings_file, @current_tests_file = positional_arguments
97
96
  @worker_count = Integer(worker_count)
98
97
  end
@@ -101,24 +100,24 @@ module XCKnife
101
100
  build_parser
102
101
  begin
103
102
  parser.parse(args)
104
- rescue OptionParser::ParseError => error
105
- warn_and_exit(error)
103
+ rescue OptionParser::ParseError => e
104
+ warn_and_exit(e)
106
105
  end
107
106
  end
108
107
 
109
108
  def build_parser
110
109
  @parser = OptionParser.new do |opts|
111
110
  opts.banner += " #{arguments_banner}"
112
- opts.on("-p", "--partition TARGETS",
113
- "Comma separated list of targets. Can be used multiple times.") do |v|
111
+ opts.on('-p', '--partition TARGETS',
112
+ 'Comma separated list of targets. Can be used multiple times.') do |v|
114
113
  @partition_names << v
115
- @partitions << v.split(",")
114
+ @partitions << v.split(',')
116
115
  end
117
- opts.on("-o", "--output FILENAME", "Output file. Defaults to STDOUT") { |v| @output_file_name = v }
118
- opts.on("-a", "--abbrev", "Results are abbreviated") { |v| @abbreviated_output = v }
119
- opts.on("-x", "--xcodebuild-output", "Output is formatted for xcodebuild") { |v| @xcodebuild_output = v }
116
+ opts.on('-o', '--output FILENAME', 'Output file. Defaults to STDOUT') { |v| @output_file_name = v }
117
+ opts.on('-a', '--abbrev', 'Results are abbreviated') { |v| @abbreviated_output = v }
118
+ opts.on('-x', '--xcodebuild-output', 'Output is formatted for xcodebuild') { |v| @xcodebuild_output = v }
120
119
 
121
- opts.on_tail("-h", "--help", "Show this message") do
120
+ opts.on_tail('-h', '--help', 'Show this message') do
122
121
  puts opts
123
122
  exit
124
123
  end
@@ -135,7 +134,7 @@ module XCKnife
135
134
 
136
135
  def arguments_banner
137
136
  optional_args = optional_arguments.map { |a| "[#{a}]" }
138
- (required_arguments + optional_args).join(" ")
137
+ (required_arguments + optional_args).join(' ')
139
138
  end
140
139
 
141
140
  def warn_and_exit(msg)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'xcknife/json_stream_parser_helper'
2
4
  require 'json'
3
5
  require 'set'
@@ -10,21 +12,31 @@ module XCKnife
10
12
 
11
13
  attr_reader :number_of_shards, :test_partitions, :stats, :relevant_partitions
12
14
 
13
- def initialize(number_of_shards, test_partitions)
15
+ def initialize(number_of_shards, test_partitions, options_for_metapartition: Array.new(test_partitions.size, {}), allow_fewer_shards: false, on_extrapolation: nil)
14
16
  @number_of_shards = number_of_shards
15
17
  @test_partitions = test_partitions.map(&:to_set)
16
18
  @relevant_partitions = test_partitions.flatten.to_set
17
19
  @stats = ResultStats.new
18
- ResultStats.members.each { |k| @stats[k] = 0}
20
+ @options_for_metapartition = options_for_metapartition.map { |o| Options::DEFAULT.merge(o) }
21
+ @allow_fewer_shards = allow_fewer_shards
22
+ @on_extrapolation = on_extrapolation
23
+ ResultStats.members.each { |k| @stats[k] = 0 }
19
24
  end
20
25
 
21
- PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count
26
+ PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count, :options
22
27
  MachineAssignment = Struct.new :test_time_map, :total_time
23
28
  ResultStats = Struct.new :historical_total_tests, :current_total_tests, :class_extrapolations, :target_extrapolations
29
+ Options = Struct.new :max_shard_count, :split_bundles_across_machines, :allow_fewer_shards do
30
+ def merge(hash)
31
+ self.class.new(*to_h.merge(hash).values_at(*members))
32
+ end
33
+ end
34
+ Options::DEFAULT = Options.new(nil, true, false).freeze
24
35
 
25
36
  class PartitionResult
26
37
  TimeImbalances = Struct.new :partition_set, :partitions
27
38
  attr_reader :stats, :test_maps, :test_times, :total_test_time, :test_time_imbalances, :test_time_for_partitions
39
+
28
40
  extend Forwardable
29
41
  delegate ResultStats.members => :@stats
30
42
 
@@ -39,10 +51,11 @@ module XCKnife
39
51
  end
40
52
 
41
53
  private
54
+
42
55
  # Yields the imbalances ratios of the partition sets, and the internal imbalance ratio of the respective partitions
43
56
  def compute_test_time_imbalances
44
57
  times = test_times
45
- average_partition_size = times.map { |l| l.inject(:+).to_f / l.size}
58
+ average_partition_size = times.map { |l| l.inject(:+).to_f / l.size }
46
59
  ideal_partition_set_avg = average_partition_size.inject(:+) / @partition_sets.size
47
60
  partition_set_imbalance = average_partition_size.map { |avg| avg / ideal_partition_set_avg }
48
61
 
@@ -75,18 +88,20 @@ module XCKnife
75
88
 
76
89
  def compute_shards_for_partitions(test_time_for_partitions)
77
90
  PartitionResult.new(@stats, split_machines_proportionally(test_time_for_partitions).map do |partition|
78
- compute_single_shards(partition.number_of_shards, partition.test_time_map)
91
+ compute_single_shards(partition.number_of_shards, partition.test_time_map, options: partition.options)
79
92
  end, test_time_for_partitions)
80
93
  end
81
94
 
82
95
  def test_time_for_partitions(historical_events, current_events = nil)
83
- analyzer = EventsAnalyzer.for(current_events, relevant_partitions)
96
+ analyzer = EventsAnalyzer.for(current_events, relevant_partitions)
84
97
  @stats[:current_total_tests] = analyzer.total_tests
85
98
  times_for_target_class = Hash.new { |h, current_target| h[current_target] = Hash.new(0) }
86
99
  each_test_event(historical_events) do |target_name, result|
87
100
  next unless relevant_partitions.include?(target_name)
101
+
88
102
  inc_stat :historical_total_tests
89
- next unless analyzer.has_test_class?(target_name, result.className)
103
+ next unless analyzer.test_class?(target_name, result.className)
104
+
90
105
  times_for_target_class[target_name][result.className] += (result.totalDuration * 1000).ceil
91
106
  end
92
107
 
@@ -94,43 +109,47 @@ module XCKnife
94
109
  hash_partitions(times_for_target_class)
95
110
  end
96
111
 
112
+ # rubocop:disable Metrics/CyclomaticComplexity
97
113
  def split_machines_proportionally(partitions)
98
114
  total = 0
99
115
  partitions.each do |test_time_map|
100
- each_duration(test_time_map) { |duration_in_milliseconds| total += duration_in_milliseconds}
116
+ each_duration(test_time_map) { |duration_in_milliseconds| total += duration_in_milliseconds }
101
117
  end
102
118
 
103
119
  used_shards = 0
104
120
  assignable_shards = number_of_shards - partitions.size
105
- partition_with_machines_list = partitions.map do |test_time_map|
121
+ partition_with_machines_list = partitions.each_with_index.map do |test_time_map, metapartition|
122
+ options = @options_for_metapartition[metapartition]
106
123
  partition_time = 0
107
- max_shard_count = test_time_map.values.map(&:size).inject(:+) || 1
108
- each_duration(test_time_map) { |duration_in_milliseconds| partition_time += duration_in_milliseconds}
124
+ max_shard_count = test_time_map.each_value.map(&:size).reduce(&:+) || 1
125
+ max_shard_count = [max_shard_count, options.max_shard_count].min if options.max_shard_count
126
+ each_duration(test_time_map) { |duration_in_milliseconds| partition_time += duration_in_milliseconds }
109
127
  n = [1 + (assignable_shards * partition_time.to_f / total).floor, max_shard_count].min
110
128
  used_shards += n
111
- PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count)
129
+ PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count, options)
112
130
  end
113
131
 
114
- fifo_with_machines_who_can_use_more_shards = partition_with_machines_list.select { |x| x.number_of_shards < x.max_shard_count}.sort_by(&:partition_time)
115
- while (number_of_shards - used_shards) > 0
132
+ fifo_with_machines_who_can_use_more_shards = partition_with_machines_list.select { |x| x.number_of_shards < x.max_shard_count }.sort_by(&:partition_time)
133
+ while number_of_shards > used_shards
116
134
  if fifo_with_machines_who_can_use_more_shards.empty?
135
+ break if @allow_fewer_shards
136
+
117
137
  raise XCKnife::XCKnifeError, "There are #{number_of_shards - used_shards} extra machines"
118
138
  end
119
139
  machine = fifo_with_machines_who_can_use_more_shards.pop
120
140
  machine.number_of_shards += 1
121
141
  used_shards += 1
122
- if machine.number_of_shards < machine.max_shard_count
123
- fifo_with_machines_who_can_use_more_shards.unshift(machine)
124
- end
142
+ fifo_with_machines_who_can_use_more_shards.unshift(machine) if machine.number_of_shards < machine.max_shard_count
125
143
  end
126
144
  partition_with_machines_list
127
145
  end
128
146
 
129
147
  # Computes a 2-aproximation to the optimal partition_time, which is an instance of the Open shop scheduling problem (which is NP-hard)
130
148
  # see: https://en.wikipedia.org/wiki/Open-shop_scheduling
131
- def compute_single_shards(number_of_shards, test_time_map)
132
- raise XCKnife::XCKnifeError, "There are not enough workers provided" if number_of_shards <= 0
133
- raise XCKnife::XCKnifeError, "Cannot shard an empty partition_time" if test_time_map.empty?
149
+ def compute_single_shards(number_of_shards, test_time_map, options: Options::DEFAULT)
150
+ raise XCKnife::XCKnifeError, 'There are not enough workers provided' if number_of_shards <= 0
151
+ raise XCKnife::XCKnifeError, 'Cannot shard an empty partition_time' if test_time_map.empty?
152
+
134
153
  assignements = Array.new(number_of_shards) { MachineAssignment.new(Hash.new { |k, v| k[v] = [] }, 0) }
135
154
 
136
155
  list_of_test_target_class_times = []
@@ -140,36 +159,61 @@ module XCKnife
140
159
  end
141
160
  end
142
161
 
143
- list_of_test_target_class_times.sort_by! { |test_target, class_name, duration_in_milliseconds| -duration_in_milliseconds }
144
- list_of_test_target_class_times.each do |test_target, class_name, duration_in_milliseconds|
162
+ # This might seem like an uncessary level of indirection, but it allows us to keep
163
+ # logic consistent regardless of the `split_bundles_across_machines` option
164
+ group = list_of_test_target_class_times.group_by do |test_target, class_name, _duration_in_milliseconds|
165
+ options.split_bundles_across_machines ? [test_target, class_name] : test_target
166
+ end
167
+
168
+ list_of_test_target_classes_times = group.map do |(test_target, _), classes|
169
+ [
170
+ test_target,
171
+ classes.map { |_test_target, class_name, _duration_in_milliseconds| class_name },
172
+ classes.reduce(0) { |total_duration, (_test_target, _class_name, duration_in_milliseconds)| total_duration + duration_in_milliseconds }
173
+ ]
174
+ end
175
+
176
+ list_of_test_target_classes_times.sort_by! { |_test_target, _class_names, duration_in_milliseconds| -duration_in_milliseconds }
177
+ list_of_test_target_classes_times.each do |test_target, class_names, duration_in_milliseconds|
145
178
  assignemnt = assignements.min_by(&:total_time)
146
- assignemnt.test_time_map[test_target] << class_name
179
+ assignemnt.test_time_map[test_target].concat class_names
147
180
  assignemnt.total_time += duration_in_milliseconds
148
181
  end
149
- raise XCKnife::XCKnifeError, "Too many shards" if assignements.any? { |a| a.test_time_map.empty? }
182
+
183
+ if (empty_test_map_assignments = assignements.select { |a| a.test_time_map.empty? }) && !empty_test_map_assignments.empty? && !options.allow_fewer_shards
184
+ test_grouping = options.split_bundles_across_machines ? 'classes' : 'targets'
185
+ raise XCKnife::XCKnifeError, "Too many shards -- #{empty_test_map_assignments.size} of #{number_of_shards} assignments are empty," \
186
+ " because there are not enough test #{test_grouping} for that many shards."
187
+ end
188
+ assignements.reject! { |a| a.test_time_map.empty? }
189
+
150
190
  assignements
151
191
  end
192
+ # rubocop:enable Metrics/CyclomaticComplexity
152
193
 
153
194
  def parse_json_stream_file(filename)
154
195
  return nil if filename.nil?
155
- return [] unless File.exists?(filename)
196
+ return [] unless File.exist?(filename)
197
+
156
198
  lines = IO.readlines(filename)
157
- lines.lazy.map { |line| OpenStruct.new(JSON.load(line)) }
199
+ lines.lazy.map { |line| OpenStruct.new(JSON.parse(line)) }
158
200
  end
159
201
 
160
202
  private
203
+
161
204
  def inc_stat(name)
162
205
  @stats[name] += 1
163
206
  end
164
207
 
165
- def each_duration(test_time_map, &block)
166
- test_time_map.each do |test_target, class_times|
167
- class_times.each do |class_name, duration_in_milliseconds|
208
+ def each_duration(test_time_map)
209
+ test_time_map.each do |_test_target, class_times|
210
+ class_times.each do |_class_name, duration_in_milliseconds|
168
211
  yield(duration_in_milliseconds)
169
212
  end
170
213
  end
171
214
  end
172
215
 
216
+ # rubocop:disable Metrics/CyclomaticComplexity
173
217
  def extrapolate_times_for_current_events(analyzer, times_for_target_class)
174
218
  median_map = {}
175
219
  times_for_target_class.each do |test_target, class_times|
@@ -179,17 +223,20 @@ module XCKnife
179
223
  all_times_for_all_classes = times_for_target_class.values.flat_map(&:values)
180
224
  median_of_targets = median(all_times_for_all_classes)
181
225
  analyzer.target_class_map.each do |test_target, class_set|
182
- if times_for_target_class.has_key?(test_target)
226
+ if times_for_target_class.key?(test_target)
183
227
  class_set.each do |clazz|
184
- unless times_for_target_class[test_target].has_key?(clazz)
185
- inc_stat :class_extrapolations
186
- times_for_target_class[test_target][clazz] = median_map[test_target]
187
- end
228
+ next if times_for_target_class[test_target].key?(clazz)
229
+
230
+ inc_stat :class_extrapolations
231
+ @on_extrapolation&.call(test_target: test_target, test_class: clazz)
232
+ times_for_target_class[test_target][clazz] = median_map[test_target]
188
233
  end
189
234
  else
190
235
  inc_stat :target_extrapolations
236
+ @on_extrapolation&.call(test_target: test_target, test_class: nil)
191
237
  class_set.each do |clazz|
192
238
  inc_stat :class_extrapolations
239
+ @on_extrapolation&.call(test_target: test_target, test_class: clazz)
193
240
  times_for_target_class[test_target][clazz] = extrapolated_duration(median_of_targets, class_set)
194
241
  end
195
242
  end
@@ -199,6 +246,7 @@ module XCKnife
199
246
  DEFAULT_EXTRAPOLATED_DURATION = 1000
200
247
  def extrapolated_duration(median_of_targets, class_set)
201
248
  return DEFAULT_EXTRAPOLATED_DURATION if median_of_targets.nil?
249
+
202
250
  median_of_targets / class_set.size
203
251
  end
204
252
 
@@ -214,10 +262,9 @@ module XCKnife
214
262
  end
215
263
  end
216
264
  ret.each_with_index do |partition, index|
217
- if partition.empty?
218
- raise XCKnife::XCKnifeError, "The following partition has no tests: #{test_partitions[index].to_a.inspect}"
219
- end
265
+ raise XCKnife::XCKnifeError, "The following partition has no tests: #{test_partitions[index].to_a.inspect}" if partition.empty?
220
266
  end
221
267
  end
222
268
  end
269
+ # rubocop:enable Metrics/CyclomaticComplexity
223
270
  end