xcknife 0.6.5 → 0.12.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,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