xcknife 0.6.6 → 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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