xcknife 0.6.5 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.5'
12
+ VERSION = '0.12.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,30 @@ 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)
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
+ ResultStats.members.each { |k| @stats[k] = 0 }
19
23
  end
20
24
 
21
- PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count
25
+ PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count, :options
22
26
  MachineAssignment = Struct.new :test_time_map, :total_time
23
27
  ResultStats = Struct.new :historical_total_tests, :current_total_tests, :class_extrapolations, :target_extrapolations
28
+ Options = Struct.new :max_shard_count, :split_bundles_across_machines, :allow_fewer_shards do
29
+ def merge(hash)
30
+ self.class.new(*to_h.merge(hash).values_at(*members))
31
+ end
32
+ end
33
+ Options::DEFAULT = Options.new(nil, true, false).freeze
24
34
 
25
35
  class PartitionResult
26
36
  TimeImbalances = Struct.new :partition_set, :partitions
27
37
  attr_reader :stats, :test_maps, :test_times, :total_test_time, :test_time_imbalances, :test_time_for_partitions
38
+
28
39
  extend Forwardable
29
40
  delegate ResultStats.members => :@stats
30
41
 
@@ -39,10 +50,11 @@ module XCKnife
39
50
  end
40
51
 
41
52
  private
53
+
42
54
  # Yields the imbalances ratios of the partition sets, and the internal imbalance ratio of the respective partitions
43
55
  def compute_test_time_imbalances
44
56
  times = test_times
45
- average_partition_size = times.map { |l| l.inject(:+).to_f / l.size}
57
+ average_partition_size = times.map { |l| l.inject(:+).to_f / l.size }
46
58
  ideal_partition_set_avg = average_partition_size.inject(:+) / @partition_sets.size
47
59
  partition_set_imbalance = average_partition_size.map { |avg| avg / ideal_partition_set_avg }
48
60
 
@@ -75,18 +87,20 @@ module XCKnife
75
87
 
76
88
  def compute_shards_for_partitions(test_time_for_partitions)
77
89
  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)
90
+ compute_single_shards(partition.number_of_shards, partition.test_time_map, options: partition.options)
79
91
  end, test_time_for_partitions)
80
92
  end
81
93
 
82
94
  def test_time_for_partitions(historical_events, current_events = nil)
83
- analyzer = EventsAnalyzer.for(current_events, relevant_partitions)
95
+ analyzer = EventsAnalyzer.for(current_events, relevant_partitions)
84
96
  @stats[:current_total_tests] = analyzer.total_tests
85
97
  times_for_target_class = Hash.new { |h, current_target| h[current_target] = Hash.new(0) }
86
98
  each_test_event(historical_events) do |target_name, result|
87
99
  next unless relevant_partitions.include?(target_name)
100
+
88
101
  inc_stat :historical_total_tests
89
- next unless analyzer.has_test_class?(target_name, result.className)
102
+ next unless analyzer.test_class?(target_name, result.className)
103
+
90
104
  times_for_target_class[target_name][result.className] += (result.totalDuration * 1000).ceil
91
105
  end
92
106
 
@@ -94,43 +108,47 @@ module XCKnife
94
108
  hash_partitions(times_for_target_class)
95
109
  end
96
110
 
111
+ # rubocop:disable Metrics/CyclomaticComplexity
97
112
  def split_machines_proportionally(partitions)
98
113
  total = 0
99
114
  partitions.each do |test_time_map|
100
- each_duration(test_time_map) { |duration_in_milliseconds| total += duration_in_milliseconds}
115
+ each_duration(test_time_map) { |duration_in_milliseconds| total += duration_in_milliseconds }
101
116
  end
102
117
 
103
118
  used_shards = 0
104
119
  assignable_shards = number_of_shards - partitions.size
105
- partition_with_machines_list = partitions.map do |test_time_map|
120
+ partition_with_machines_list = partitions.each_with_index.map do |test_time_map, metapartition|
121
+ options = @options_for_metapartition[metapartition]
106
122
  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}
123
+ max_shard_count = test_time_map.each_value.map(&:size).reduce(&:+) || 1
124
+ max_shard_count = [max_shard_count, options.max_shard_count].min if options.max_shard_count
125
+ each_duration(test_time_map) { |duration_in_milliseconds| partition_time += duration_in_milliseconds }
109
126
  n = [1 + (assignable_shards * partition_time.to_f / total).floor, max_shard_count].min
110
127
  used_shards += n
111
- PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count)
128
+ PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count, options)
112
129
  end
113
130
 
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
131
+ 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)
132
+ while number_of_shards > used_shards
116
133
  if fifo_with_machines_who_can_use_more_shards.empty?
134
+ break if @allow_fewer_shards
135
+
117
136
  raise XCKnife::XCKnifeError, "There are #{number_of_shards - used_shards} extra machines"
118
137
  end
119
138
  machine = fifo_with_machines_who_can_use_more_shards.pop
120
139
  machine.number_of_shards += 1
121
140
  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
141
+ fifo_with_machines_who_can_use_more_shards.unshift(machine) if machine.number_of_shards < machine.max_shard_count
125
142
  end
126
143
  partition_with_machines_list
127
144
  end
128
145
 
129
146
  # Computes a 2-aproximation to the optimal partition_time, which is an instance of the Open shop scheduling problem (which is NP-hard)
130
147
  # 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?
148
+ def compute_single_shards(number_of_shards, test_time_map, options: Options::DEFAULT)
149
+ raise XCKnife::XCKnifeError, 'There are not enough workers provided' if number_of_shards <= 0
150
+ raise XCKnife::XCKnifeError, 'Cannot shard an empty partition_time' if test_time_map.empty?
151
+
134
152
  assignements = Array.new(number_of_shards) { MachineAssignment.new(Hash.new { |k, v| k[v] = [] }, 0) }
135
153
 
136
154
  list_of_test_target_class_times = []
@@ -140,36 +158,61 @@ module XCKnife
140
158
  end
141
159
  end
142
160
 
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|
161
+ # This might seem like an uncessary level of indirection, but it allows us to keep
162
+ # logic consistent regardless of the `split_bundles_across_machines` option
163
+ group = list_of_test_target_class_times.group_by do |test_target, class_name, _duration_in_milliseconds|
164
+ options.split_bundles_across_machines ? [test_target, class_name] : test_target
165
+ end
166
+
167
+ list_of_test_target_classes_times = group.map do |(test_target, _), classes|
168
+ [
169
+ test_target,
170
+ classes.map { |_test_target, class_name, _duration_in_milliseconds| class_name },
171
+ classes.reduce(0) { |total_duration, (_test_target, _class_name, duration_in_milliseconds)| total_duration + duration_in_milliseconds }
172
+ ]
173
+ end
174
+
175
+ list_of_test_target_classes_times.sort_by! { |_test_target, _class_names, duration_in_milliseconds| -duration_in_milliseconds }
176
+ list_of_test_target_classes_times.each do |test_target, class_names, duration_in_milliseconds|
145
177
  assignemnt = assignements.min_by(&:total_time)
146
- assignemnt.test_time_map[test_target] << class_name
178
+ assignemnt.test_time_map[test_target].concat class_names
147
179
  assignemnt.total_time += duration_in_milliseconds
148
180
  end
149
- raise XCKnife::XCKnifeError, "Too many shards" if assignements.any? { |a| a.test_time_map.empty? }
181
+
182
+ if (empty_test_map_assignments = assignements.select { |a| a.test_time_map.empty? }) && !empty_test_map_assignments.empty? && !options.allow_fewer_shards
183
+ test_grouping = options.split_bundles_across_machines ? 'classes' : 'targets'
184
+ raise XCKnife::XCKnifeError, "Too many shards -- #{empty_test_map_assignments.size} of #{number_of_shards} assignments are empty," \
185
+ " because there are not enough test #{test_grouping} for that many shards."
186
+ end
187
+ assignements.reject! { |a| a.test_time_map.empty? }
188
+
150
189
  assignements
151
190
  end
191
+ # rubocop:enable Metrics/CyclomaticComplexity
152
192
 
153
193
  def parse_json_stream_file(filename)
154
194
  return nil if filename.nil?
155
- return [] unless File.exists?(filename)
195
+ return [] unless File.exist?(filename)
196
+
156
197
  lines = IO.readlines(filename)
157
- lines.lazy.map { |line| OpenStruct.new(JSON.load(line)) }
198
+ lines.lazy.map { |line| OpenStruct.new(JSON.parse(line)) }
158
199
  end
159
200
 
160
201
  private
202
+
161
203
  def inc_stat(name)
162
204
  @stats[name] += 1
163
205
  end
164
206
 
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|
207
+ def each_duration(test_time_map)
208
+ test_time_map.each do |_test_target, class_times|
209
+ class_times.each do |_class_name, duration_in_milliseconds|
168
210
  yield(duration_in_milliseconds)
169
211
  end
170
212
  end
171
213
  end
172
214
 
215
+ # rubocop:disable Metrics/CyclomaticComplexity
173
216
  def extrapolate_times_for_current_events(analyzer, times_for_target_class)
174
217
  median_map = {}
175
218
  times_for_target_class.each do |test_target, class_times|
@@ -179,9 +222,9 @@ module XCKnife
179
222
  all_times_for_all_classes = times_for_target_class.values.flat_map(&:values)
180
223
  median_of_targets = median(all_times_for_all_classes)
181
224
  analyzer.target_class_map.each do |test_target, class_set|
182
- if times_for_target_class.has_key?(test_target)
225
+ if times_for_target_class.key?(test_target)
183
226
  class_set.each do |clazz|
184
- unless times_for_target_class[test_target].has_key?(clazz)
227
+ unless times_for_target_class[test_target].key?(clazz)
185
228
  inc_stat :class_extrapolations
186
229
  times_for_target_class[test_target][clazz] = median_map[test_target]
187
230
  end
@@ -199,6 +242,7 @@ module XCKnife
199
242
  DEFAULT_EXTRAPOLATED_DURATION = 1000
200
243
  def extrapolated_duration(median_of_targets, class_set)
201
244
  return DEFAULT_EXTRAPOLATED_DURATION if median_of_targets.nil?
245
+
202
246
  median_of_targets / class_set.size
203
247
  end
204
248
 
@@ -214,10 +258,9 @@ module XCKnife
214
258
  end
215
259
  end
216
260
  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
261
+ raise XCKnife::XCKnifeError, "The following partition has no tests: #{test_partitions[index].to_a.inspect}" if partition.empty?
220
262
  end
221
263
  end
222
264
  end
265
+ # rubocop:enable Metrics/CyclomaticComplexity
223
266
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'pp'
3
5
  require 'fileutils'
@@ -5,6 +7,10 @@ require 'tmpdir'
5
7
  require 'ostruct'
6
8
  require 'set'
7
9
  require 'logger'
10
+ require 'shellwords'
11
+ require 'open3'
12
+ require 'xcknife/exceptions'
13
+ require 'etc'
8
14
 
9
15
  module XCKnife
10
16
  class TestDumper
@@ -14,85 +20,82 @@ module XCKnife
14
20
 
15
21
  attr_reader :logger
16
22
 
17
- def initialize(args)
23
+ def initialize(args, logger: Logger.new($stdout, progname: 'xcknife test dumper'))
18
24
  @debug = false
19
25
  @max_retry_count = 150
20
26
  @temporary_output_folder = nil
21
27
  @xcscheme_file = nil
22
28
  @parser = build_parser
29
+ @naive_dump_bundle_names = []
30
+ @skip_dump_bundle_names = []
31
+ @simctl_timeout = 0
23
32
  parse_arguments(args)
24
- @device_id ||= "booted"
25
- @logger = Logger.new($stdout)
33
+ @device_id ||= 'booted'
34
+ @logger = logger
26
35
  @logger.level = @debug ? Logger::DEBUG : Logger::FATAL
27
36
  @parser = nil
28
37
  end
29
38
 
30
39
  def run
31
- helper = TestDumperHelper.new(@device_id, @max_retry_count, @debug, @logger)
40
+ helper = TestDumperHelper.new(@device_id, @max_retry_count, @debug, @logger, @dylib_logfile_path,
41
+ naive_dump_bundle_names: @naive_dump_bundle_names, skip_dump_bundle_names: @skip_dump_bundle_names, simctl_timeout: @simctl_timeout)
32
42
  extra_environment_variables = parse_scheme_file
33
43
  logger.info { "Environment variables from xcscheme: #{extra_environment_variables.pretty_inspect}" }
34
- output_fd = File.open(@output_file, "w")
44
+ output_fd = File.open(@output_file, 'w')
35
45
  if @temporary_output_folder.nil?
36
- Dir.mktmpdir("xctestdumper_") do |outfolder|
46
+ Dir.mktmpdir('xctestdumper_') do |outfolder|
37
47
  list_tests(extra_environment_variables, helper, outfolder, output_fd)
38
48
  end
39
49
  else
40
- unless File.directory?(@temporary_output_folder)
41
- puts "Error no such directory: #{@temporary_output_folder}"
42
- exit 1
43
- end
50
+ raise TestDumpError, "Error no such directory: #{@temporary_output_folder}" unless File.directory?(@temporary_output_folder)
44
51
 
45
- if Dir.entries(@temporary_output_folder).any? { |f| File.file?(File.join(@temporary_output_folder,f)) }
46
- puts "Warning: #{@temporary_output_folder} is not empty! Files can be overwritten."
47
- end
52
+ puts "Warning: #{@temporary_output_folder} is not empty! Files can be overwritten." if Dir.entries(@temporary_output_folder).any? { |f| File.file?(File.join(@temporary_output_folder, f)) }
48
53
  list_tests(extra_environment_variables, helper, File.absolute_path(@temporary_output_folder), output_fd)
49
54
  end
50
55
  output_fd.close
51
- puts "Done listing test methods"
56
+ puts 'Done listing test methods'
52
57
  end
53
58
 
54
59
  private
60
+
55
61
  def list_tests(extra_environment_variables, helper, outfolder, output_fd)
56
62
  helper.call(@derived_data_folder, outfolder, extra_environment_variables).each do |test_specification|
57
63
  concat_to_file(test_specification, output_fd)
58
64
  end
59
65
  end
60
66
 
61
-
62
67
  def parse_scheme_file
63
68
  return {} unless @xcscheme_file
64
- unless File.exists?(@xcscheme_file)
65
- puts "Error: no such xcscheme file: #{@xcscheme_file}"
66
- exit 1
67
- end
69
+ raise ArgumentError, "Error: no such xcscheme file: #{@xcscheme_file}" unless File.exist?(@xcscheme_file)
70
+
68
71
  XCKnife::XcschemeAnalyzer.extract_environment_variables(IO.read(@xcscheme_file))
69
72
  end
70
73
 
71
74
  def parse_arguments(args)
72
75
  positional_arguments = parse_options(args)
73
- if positional_arguments.size < required_arguments.size
74
- warn_and_exit("You must specify *all* required arguments: #{required_arguments.join(", ")}")
75
- end
76
+ warn_and_exit("You must specify *all* required arguments: #{required_arguments.join(', ')}") if positional_arguments.size < required_arguments.size
76
77
  @derived_data_folder, @output_file, @device_id = positional_arguments
77
78
  end
78
79
 
79
80
  def parse_options(args)
80
- begin
81
- return @parser.parse(args)
82
- rescue OptionParser::ParseError => error
83
- warn_and_exit(error)
84
- end
81
+ @parser.parse(args)
82
+ rescue OptionParser::ParseError => e
83
+ warn_and_exit(e)
85
84
  end
86
85
 
87
86
  def build_parser
88
87
  OptionParser.new do |opts|
89
88
  opts.banner += " #{arguments_banner}"
90
- opts.on("-d", "--debug", "Debug mode enabled") { |v| @debug = v }
91
- opts.on("-r", "--retry-count COUNT", "Max retry count for simulator output", Integer) { |v| @max_retry_count = v }
92
- opts.on("-t", "--temporary-output OUTPUT_FOLDER", "Sets temporary Output folder") { |v| @temporary_output_folder = v }
93
- opts.on("-s", "--scheme XCSCHEME_FILE", "Reads environments variables from the xcscheme file") { |v| @xcscheme_file = v }
94
-
95
- opts.on_tail("-h", "--help", "Show this message") do
89
+ opts.on('-d', '--debug', 'Debug mode enabled') { |v| @debug = v }
90
+ opts.on('-r', '--retry-count COUNT', 'Max retry count for simulator output', Integer) { |v| @max_retry_count = v }
91
+ opts.on('-x', '--simctl-timeout SECONDS', 'Max allowed time in seconds for simctl commands', Integer) { |v| @simctl_timeout = v }
92
+ opts.on('-t', '--temporary-output OUTPUT_FOLDER', 'Sets temporary Output folder') { |v| @temporary_output_folder = v }
93
+ opts.on('-s', '--scheme XCSCHEME_FILE', 'Reads environments variables from the xcscheme file') { |v| @xcscheme_file = v }
94
+ opts.on('-l', '--dylib_logfile DYLIB_LOG_FILE', 'Path for dylib log file') { |v| @dylib_logfile_path = v }
95
+ opts.on('--naive-dump TEST_BUNDLE_NAMES', 'List of test bundles to dump using static analysis', Array) { |v| @naive_dump_bundle_names = v }
96
+ opts.on('--skip-dump TEST_BUNDLE_NAMES', 'List of test bundles to skip dumping', Array) { |v| @skip_dump_bundle_names = v }
97
+
98
+ opts.on_tail('-h', '--help', 'Show this message') do
96
99
  puts opts
97
100
  exit
98
101
  end
@@ -104,23 +107,22 @@ module XCKnife
104
107
  end
105
108
 
106
109
  def optional_arguments
107
- %w[device_id]
110
+ %w[device_id simctl_timeout]
108
111
  end
109
112
 
110
113
  def arguments_banner
111
114
  optional_args = optional_arguments.map { |a| "[#{a}]" }
112
- (required_arguments + optional_args).join(" ")
115
+ (required_arguments + optional_args).join(' ')
113
116
  end
114
117
 
115
118
  def warn_and_exit(msg)
116
- warn "#{msg.to_s.capitalize} \n\n#{@parser}"
117
- exit 1
119
+ raise TestDumpError, "#{msg.to_s.capitalize} \n\n#{@parser}"
118
120
  end
119
121
 
120
122
  def concat_to_file(test_specification, output_fd)
121
123
  file = test_specification.json_stream_file
122
124
  IO.readlines(file).each do |line|
123
- event = OpenStruct.new(JSON.load(line))
125
+ event = OpenStruct.new(JSON.parse(line))
124
126
  if should_test_event_be_ignored?(test_specification, event)
125
127
  logger.info "Skipped test dumper line #{line}"
126
128
  else
@@ -133,8 +135,9 @@ module XCKnife
133
135
 
134
136
  # Current limitation: this only supports class level skipping
135
137
  def should_test_event_be_ignored?(test_specification, event)
136
- return false unless event["test"] == "1"
137
- test_specification.skip_test_identifiers.include?(event["className"])
138
+ return false unless event['test'] == '1'
139
+
140
+ test_specification.skip_test_identifiers.include?(event['className'])
138
141
  end
139
142
  end
140
143
 
@@ -143,84 +146,190 @@ module XCKnife
143
146
 
144
147
  attr_reader :logger
145
148
 
146
- def initialize(device_id, max_retry_count, debug, logger)
149
+ # rubocop:disable Metrics/ParameterLists
150
+ def initialize(device_id, max_retry_count, debug, logger, dylib_logfile_path,
151
+ naive_dump_bundle_names: [], skip_dump_bundle_names: [], simctl_timeout: 0)
147
152
  @xcode_path = `xcode-select -p`.strip
148
153
  @simctl_path = `xcrun -f simctl`.strip
149
- @platforms_path = "#{@xcode_path}/Platforms/"
150
- @platform_path = "#{@platforms_path}/iPhoneSimulator.platform"
151
- @sdk_path = "#{@platform_path}/Developer/SDKs/iPhoneSimulator.sdk"
154
+ @nm_path = `xcrun -f nm`.strip
155
+ @swift_path = `xcrun -f swift`.strip
156
+ @platforms_path = File.join(@xcode_path, 'Platforms')
157
+ @platform_path = File.join(@platforms_path, 'iPhoneSimulator.platform')
158
+ @sdk_path = File.join(@platform_path, 'Developer/SDKs/iPhoneSimulator.sdk')
152
159
  @testroot = nil
153
160
  @device_id = device_id
154
161
  @max_retry_count = max_retry_count
162
+ @simctl_timeout = simctl_timeout
155
163
  @logger = logger
156
164
  @debug = debug
165
+ @dylib_logfile_path = dylib_logfile_path if dylib_logfile_path
166
+ @naive_dump_bundle_names = naive_dump_bundle_names
167
+ @skip_dump_bundle_names = skip_dump_bundle_names
157
168
  end
169
+ # rubocop:enable Metrics/ParameterLists
158
170
 
159
171
  def call(derived_data_folder, list_folder, extra_environment_variables = {})
160
- @testroot = "#{derived_data_folder}/Build/Products/"
161
- xctestrun_file = Dir["#{@testroot}/*.xctestrun"].first
162
- if xctestrun_file.nil?
163
- puts "No xctestrun on #{@testroot}"
164
- exit 1
165
- end
172
+ @testroot = File.join(derived_data_folder, 'Build', 'Products')
173
+ xctestrun_file = Dir[File.join(@testroot, '*.xctestrun')].first
174
+ raise ArgumentError, "No xctestrun on #{@testroot}" if xctestrun_file.nil?
175
+
166
176
  xctestrun_as_json = `plutil -convert json -o - "#{xctestrun_file}"`
167
177
  FileUtils.mkdir_p(list_folder)
168
- JSON.load(xctestrun_as_json).map do |test_bundle_name, test_bundle|
169
- test_specification = list_tests_wiht_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
170
- wait_test_dumper_completion(test_specification.json_stream_file)
171
- test_specification
172
- end
178
+ list_tests(JSON.parse(xctestrun_as_json), list_folder, extra_environment_variables)
173
179
  end
174
180
 
175
181
  private
176
- def list_tests_wiht_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
177
- env_variables = test_bundle["EnvironmentVariables"]
178
- testing_env_variables = test_bundle["TestingEnvironmentVariables"]
179
- outpath = "#{list_folder}/#{test_bundle_name}"
180
- test_host = replace_vars(test_bundle["TestHostPath"])
181
- test_bundle_path = replace_vars(test_bundle["TestBundlePath"], test_host)
182
- test_dumper_path = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'TestDumper', 'TestDumper.dylib'))
183
- unless File.exist?(test_dumper_path)
184
- warn "Could not find TestDumpber.dylib on #{test_dumper_path}"
185
- exit 1
182
+
183
+ attr_reader :testroot
184
+
185
+ def test_groups(xctestrun)
186
+ xctestrun.group_by do |test_bundle_name, _test_bundle|
187
+ if @skip_dump_bundle_names.include?(test_bundle_name)
188
+ 'single'
189
+ elsif @naive_dump_bundle_names.include?(test_bundle_name)
190
+ 'nm'
191
+ else
192
+ 'simctl'
193
+ end
186
194
  end
195
+ end
196
+
197
+ # This executes naive test dumping in parallel by queueing up items onto a work queue to process
198
+ # with 1 new thread per processor. Results are placed onto a threadsafe spec queue to avoid writing
199
+ # to an object between threads, then popped off re-inserting them to our list of test results.
200
+ # rubocop:disable Metrics/CyclomaticComplexity
201
+ def list_tests(xctestrun, list_folder, extra_environment_variables)
202
+ xctestrun.reject! { |test_bundle_name, _| test_bundle_name == '__xctestrun_metadata__' }
203
+
204
+ test_runs_by_method = test_groups(xctestrun)
205
+ spec_queue = Queue.new
206
+ nm_bundle_queue = Queue.new
207
+ results = []
208
+ single_tests = test_runs_by_method['single'] || []
209
+ nm_tests = test_runs_by_method['nm'] || []
210
+ simctl_tests = test_runs_by_method['simctl'] || []
211
+
212
+ single_tests.each do |test_bundle_name, test_bundle|
213
+ logger.info { "Skipping dumping tests in `#{test_bundle_name}` -- writing out fake event" }
214
+ spec_queue << list_single_test(list_folder, test_bundle, test_bundle_name)
215
+ end
216
+
217
+ simctl_tests.each do |test_bundle_name, test_bundle|
218
+ test_spec = list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
219
+ wait_test_dumper_completion(test_spec.json_stream_file)
220
+
221
+ spec_queue << test_spec
222
+ end
223
+
224
+ nm_tests.each { |item| nm_bundle_queue << item }
225
+
226
+ [Etc.nprocessors, nm_bundle_queue.size].min.times.map do
227
+ nm_bundle_queue << :stop
228
+
229
+ Thread.new do
230
+ Thread.current.abort_on_exception = true
231
+
232
+ until (item = nm_bundle_queue.pop) == :stop
233
+ test_bundle_name, test_bundle = item
234
+ spec_queue << list_tests_with_nm(list_folder, test_bundle, test_bundle_name)
235
+ end
236
+ end
237
+ end.each(&:join)
238
+
239
+ results << spec_queue.pop until spec_queue.empty?
240
+
241
+ results
242
+ end
243
+ # rubocop:enable Metrics/CyclomaticComplexity
244
+
245
+ def list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
246
+ env_variables = test_bundle['EnvironmentVariables']
247
+ testing_env_variables = test_bundle['TestingEnvironmentVariables']
248
+ outpath = File.join(list_folder, test_bundle_name)
249
+ test_host = replace_vars(test_bundle['TestHostPath'])
250
+ test_bundle_path = replace_vars(test_bundle['TestBundlePath'], test_host)
251
+ test_dumper_path = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'TestDumper', 'TestDumper.dylib'))
252
+ raise TestDumpError, "Could not find TestDumper.dylib on #{test_dumper_path}" unless File.exist?(test_dumper_path)
187
253
 
188
- is_logic_test = test_bundle["TestHostBundleIdentifier"].nil?
254
+ is_logic_test = test_bundle['TestHostBundleIdentifier'].nil?
189
255
  env = simctl_child_attrs(
190
- "XCTEST_TYPE" => is_logic_test ? "LOGICTEST" : "APPTEST",
191
- "XCTEST_TARGET" => test_bundle_name,
192
- "TestDumperOutputPath" => outpath,
193
- "IDE_INJECTION_PATH" => testing_env_variables["DYLD_INSERT_LIBRARIES"],
194
- "XCInjectBundleInto" => testing_env_variables["XCInjectBundleInto"],
195
- "XCInjectBundle" => test_bundle_path,
196
- "TestBundleLocation" => test_bundle_path,
197
- "OS_ACTIVITY_MODE" => "disable",
198
- "DYLD_PRINT_LIBRARIES" => "YES",
199
- "DYLD_PRINT_ENV" => "YES",
200
- "DYLD_ROOT_PATH" => @sdk_path,
201
- "DYLD_LIBRARY_PATH" => env_variables["DYLD_LIBRARY_PATH"],
202
- "DYLD_FRAMEWORK_PATH" => env_variables["DYLD_FRAMEWORK_PATH"],
203
- "DYLD_FALLBACK_LIBRARY_PATH" => "#{@sdk_path}/usr/lib",
204
- "DYLD_FALLBACK_FRAMEWORK_PATH" => "#{@platform_path}/Developer/Library/Frameworks",
205
- "DYLD_INSERT_LIBRARIES" => test_dumper_path,
256
+ 'XCTEST_TYPE' => xctest_type(test_bundle),
257
+ 'XCTEST_TARGET' => test_bundle_name,
258
+ 'TestDumperOutputPath' => outpath,
259
+ 'IDE_INJECTION_PATH' => testing_env_variables['DYLD_INSERT_LIBRARIES'],
260
+ 'XCInjectBundleInto' => testing_env_variables['XCInjectBundleInto'],
261
+ 'XCInjectBundle' => test_bundle_path,
262
+ 'TestBundleLocation' => test_bundle_path,
263
+ 'OS_ACTIVITY_MODE' => 'disable',
264
+ 'DYLD_PRINT_LIBRARIES' => 'YES',
265
+ 'DYLD_PRINT_ENV' => 'YES',
266
+ 'DYLD_ROOT_PATH' => @sdk_path,
267
+ 'DYLD_LIBRARY_PATH' => env_variables['DYLD_LIBRARY_PATH'],
268
+ 'DYLD_FRAMEWORK_PATH' => env_variables['DYLD_FRAMEWORK_PATH'],
269
+ 'DYLD_FALLBACK_LIBRARY_PATH' => "#{@sdk_path}/usr/lib",
270
+ 'DYLD_FALLBACK_FRAMEWORK_PATH' => "#{@platform_path}/Developer/Library/Frameworks",
271
+ 'DYLD_INSERT_LIBRARIES' => test_dumper_path
206
272
  )
207
273
  env.merge!(simctl_child_attrs(extra_environment_variables))
208
274
  inject_vars(env, test_host)
209
- FileUtils.remove(outpath) if File.exists?(outpath)
275
+ FileUtils.rm_f(outpath)
210
276
  logger.info { "Temporary TestDumper file for #{test_bundle_name} is #{outpath}" }
211
277
  if is_logic_test
212
278
  run_logic_test(env, test_host, test_bundle_path)
213
279
  else
214
280
  install_app(test_host)
215
- test_host_bundle_identifier = replace_vars(test_bundle["TestHostBundleIdentifier"], test_host)
281
+ test_host_bundle_identifier = replace_vars(test_bundle['TestHostBundleIdentifier'], test_host)
216
282
  run_apptest(env, test_host_bundle_identifier, test_bundle_path)
217
283
  end
218
- return TestSpecification.new outpath, discover_tests_to_skip(test_bundle)
284
+ TestSpecification.new outpath, discover_tests_to_skip(test_bundle)
285
+ end
286
+
287
+ # Improvement?: assume that everything in the historical info is correct, so dont simctl or nm, and just spit out exactly what it said the classes were
288
+
289
+ def list_tests_with_nm(list_folder, test_bundle, test_bundle_name)
290
+ output_methods(list_folder, test_bundle, test_bundle_name) do |test_bundle_path|
291
+ methods = []
292
+ swift_demangled_nm(test_bundle_path) do |output|
293
+ output.each_line do |line|
294
+ next unless (method = method_from_nm_line(line))
295
+
296
+ methods << method
297
+ end
298
+ end
299
+ methods
300
+ end
301
+ end
302
+
303
+ def list_single_test(list_folder, test_bundle, test_bundle_name)
304
+ output_methods(list_folder, test_bundle, test_bundle_name) do
305
+ [{ class: test_bundle_name, method: 'test' }]
306
+ end
307
+ end
308
+
309
+ def output_methods(list_folder, test_bundle, test_bundle_name)
310
+ outpath = File.join(list_folder, test_bundle_name)
311
+ logger.info { "Writing out TestDumper file for #{test_bundle_name} to #{outpath}" }
312
+ test_specification = TestSpecification.new outpath, discover_tests_to_skip(test_bundle)
313
+
314
+ test_bundle_path = replace_vars(test_bundle['TestBundlePath'], replace_vars(test_bundle['TestHostPath']))
315
+ methods = yield(test_bundle_path)
316
+
317
+ test_type = xctest_type(test_bundle)
318
+ File.open test_specification.json_stream_file, 'a' do |f|
319
+ f << JSON.dump(message: 'Starting Test Dumper', event: 'begin-test-suite', testType: test_type) << "\n"
320
+ f << JSON.dump(event: 'begin-ocunit', bundleName: File.basename(test_bundle_path), targetName: test_bundle_name) << "\n"
321
+ methods.map { |method| method[:class] }.uniq.each do |class_name|
322
+ f << JSON.dump(test: '1', className: class_name, event: 'end-test', totalDuration: '0') << "\n"
323
+ end
324
+ f << JSON.dump(message: 'Completed Test Dumper', event: 'end-action', testType: test_type) << "\n"
325
+ end
326
+
327
+ test_specification
219
328
  end
220
329
 
221
330
  def discover_tests_to_skip(test_bundle)
222
- identifier_for_test_method = "/"
223
- skip_test_identifiers = test_bundle["SkipTestIdentifiers"] || []
331
+ identifier_for_test_method = '/'
332
+ skip_test_identifiers = test_bundle['SkipTestIdentifiers'] || []
224
333
  skip_test_identifiers.reject { |i| i.include?(identifier_for_test_method) }.to_set
225
334
  end
226
335
 
@@ -228,15 +337,35 @@ module XCKnife
228
337
  @simctl_path
229
338
  end
230
339
 
231
- def replace_vars(str, testhost = "<UNKNOWN>")
232
- str.gsub("__PLATFORMS__", @platforms_path).
233
- gsub("__TESTHOST__", testhost).
234
- gsub("__TESTROOT__", @testroot)
340
+ def wrapped_simctl(args)
341
+ [*gtimeout, simctl] + args
342
+ end
343
+
344
+ def gtimeout
345
+ return [] unless @simctl_timeout.positive?
346
+
347
+ path = gtimeout_path
348
+ if path.empty?
349
+ puts "warning: simctl_timeout specified but 'gtimeout' is not installed. The specified timeout will be ignored."
350
+ return []
351
+ end
352
+
353
+ [path, '-k', '5', @simctl_timeout.to_s]
354
+ end
355
+
356
+ def gtimeout_path
357
+ `which gtimeout`.strip
358
+ end
359
+
360
+ def replace_vars(str, testhost = '<UNKNOWN>')
361
+ str.gsub('__PLATFORMS__', @platforms_path)
362
+ .gsub('__TESTHOST__', testhost)
363
+ .gsub('__TESTROOT__', testroot)
235
364
  end
236
365
 
237
366
  def inject_vars(env, test_host)
238
367
  env.each do |k, v|
239
- env[k] = replace_vars(v || "", test_host)
368
+ env[k] = replace_vars(v || '', test_host)
240
369
  end
241
370
  end
242
371
 
@@ -247,49 +376,91 @@ module XCKnife
247
376
  end
248
377
 
249
378
  def install_app(test_host_path)
250
- until system("#{simctl} install #{@device_id} '#{test_host_path}'")
251
- sleep 0.1
379
+ retries_count = 0
380
+ max_retry_count = 3
381
+ until (retries_count > max_retry_count) || call_simctl(['install', @device_id, test_host_path])
382
+ retries_count += 1
383
+ call_simctl ['shutdown', @device_id]
384
+ call_simctl ['boot', @device_id]
385
+ sleep 1.0
252
386
  end
387
+
388
+ raise TestDumpError, "Installing #{test_host_path} failed" if retries_count > max_retry_count
253
389
  end
254
390
 
255
391
  def wait_test_dumper_completion(file)
256
392
  retries_count = 0
257
- until has_test_dumper_terminated?(file) do
393
+ until test_dumper_terminated?(file)
258
394
  retries_count += 1
259
- if retries_count == @max_retry_count
260
- puts "Timeout error on: #{file}"
261
- exit 1
262
- end
395
+ raise TestDumpError, "Timeout error on: #{file}" if retries_count == @max_retry_count
396
+
263
397
  sleep 0.1
264
398
  end
265
399
  end
266
400
 
267
- def has_test_dumper_terminated?(file)
268
- return false unless File.exists?(file)
401
+ def test_dumper_terminated?(file)
402
+ return false unless File.exist?(file)
403
+
269
404
  last_line = `tail -n 1 "#{file}"`
270
- return /Completed Test Dumper/.match(last_line)
405
+ last_line.include?('Completed Test Dumper')
271
406
  end
272
407
 
273
408
  def run_apptest(env, test_host_bundle_identifier, test_bundle_path)
274
- call_simctl env, "launch #{@device_id} '#{test_host_bundle_identifier}' -XCTest All '#{test_bundle_path}'"
409
+ return if call_simctl(['launch', @device_id, test_host_bundle_identifier, '-XCTest', 'All', dylib_logfile_path, test_bundle_path], env: env)
410
+
411
+ raise TestDumpError, "Launching #{test_bundle_path} in #{test_host_bundle_identifier} failed"
275
412
  end
276
413
 
277
414
  def run_logic_test(env, test_host, test_bundle_path)
278
- call_simctl env, "spawn #{@device_id} '#{test_host}' -XCTest All '#{test_bundle_path}'#{redirect_output}"
279
- end
415
+ opts = @debug ? {} : { err: '/dev/null' }
416
+ return if call_simctl(['spawn', @device_id, test_host, '-XCTest', 'All', dylib_logfile_path, test_bundle_path], env: env, **opts)
280
417
 
281
- def redirect_output
282
- return '' unless @debug
283
- ' 2> /dev/null'
418
+ raise TestDumpError, "Spawning #{test_bundle_path} in #{test_host} failed"
284
419
  end
285
420
 
286
- def call_simctl(env, string_args)
287
- cmd = "#{simctl} #{string_args}"
421
+ def call_simctl(args, env: {}, **spawn_opts)
422
+ args = wrapped_simctl(args)
423
+ cmd = Shellwords.shelljoin(args)
288
424
  puts "Running:\n$ #{cmd}"
289
425
  logger.info { "Environment variables:\n #{env.pretty_print_inspect}" }
290
- unless system(env, cmd)
291
- puts "Simctl errored with the following env:\n #{env.pretty_print_inspect}"
426
+
427
+ ret = system(env, *args, **spawn_opts)
428
+ puts "Simctl errored with the following env:\n #{env.pretty_print_inspect}" unless ret
429
+ ret
430
+ end
431
+
432
+ def dylib_logfile_path
433
+ @dylib_logfile_path ||= '/tmp/xcknife_testdumper_dylib.log'
434
+ end
435
+
436
+ def xctest_type(test_bundle)
437
+ if test_bundle['TestHostBundleIdentifier'].nil?
438
+ 'LOGICTEST'
439
+ else
440
+ 'APPTEST'
292
441
  end
293
442
  end
443
+
444
+ def swift_demangled_nm(test_bundle_path, &block)
445
+ Open3.pipeline_r([@nm_path, File.join(test_bundle_path, File.basename(test_bundle_path, '.xctest'))], [@swift_path, 'demangle'], &block)
446
+ end
447
+
448
+ def method_from_nm_line(line)
449
+ return unless line.strip =~ /^
450
+ [\da-f]+\s # address
451
+ [tT]\s # symbol type
452
+ (?: # method
453
+ -\[(.+)\s(test.+)\] # objc instance method
454
+ | # or swift instance method
455
+ _? # only present on Xcode 10.0 and below
456
+ (?:@objc\s)? # optional objc annotation
457
+ (?:[^.]+\.)? # module name
458
+ (.+) # class name
459
+ \.(test.+)\s->\s\(\) # method signature
460
+ )
461
+ $/ox
462
+
463
+ { class: Regexp.last_match(1) || Regexp.last_match(3), method: Regexp.last_match(2) || Regexp.last_match(4) }
464
+ end
294
465
  end
295
466
  end