xcknife 0.6.3 → 0.11.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,6 +1,7 @@
1
1
  #!/bin/bash
2
+ rm -rf TestDumper.dylib testdumperbuild
2
3
  xcodebuild -project TestDumper.xcodeproj \
3
- -configuration Release \
4
+ -configuration Debug \
4
5
  -derivedDataPath testdumperbuild \
5
6
  -scheme TestDumper \
6
- -sdk iphonesimulator build
7
+ -sdk iphonesimulator build ONLY_ACTIVE_ARCH=NO
@@ -26,6 +26,7 @@ def run(historical_file, current_file)
26
26
  partition_set.each do |partition|
27
27
  puts "target name for worker #{shard_number} = #{target_name}"
28
28
  puts "only is: #{xctool_only_arguments(partition).inspect}"
29
+ puts "skip-only is: #{xcodebuild_skip_arguments(partition, result.test_time_for_partitions).inspect}"
29
30
  shard_number += 1
30
31
  end
31
32
  end
@@ -4,7 +4,8 @@ require 'xcknife/xctool_cmd_helper'
4
4
  require 'xcknife/runner'
5
5
  require 'xcknife/test_dumper'
6
6
  require 'xcknife/exceptions'
7
+ require 'xcknife/xcscheme_analyzer'
7
8
 
8
9
  module XCKnife
9
- VERSION = '0.6.3'
10
+ VERSION = '0.11.0'
10
11
  end
@@ -1,6 +1,8 @@
1
1
  module XCKnife
2
2
  # Base error class for xcknife
3
- XCKnifeError = Class.new(StandardError)
3
+ XCKnifeError = Class.new(RuntimeError)
4
+
5
+ TestDumpError = Class.new(XCKnifeError)
4
6
 
5
7
  StreamParsingError = Class.new(XCKnifeError)
6
- end
8
+ end
@@ -10,31 +10,40 @@ module XCKnife
10
10
 
11
11
  attr_reader :number_of_shards, :test_partitions, :stats, :relevant_partitions
12
12
 
13
- def initialize(number_of_shards, test_partitions)
13
+ def initialize(number_of_shards, test_partitions, options_for_metapartition: Array.new(test_partitions.size, {}), allow_fewer_shards: false)
14
14
  @number_of_shards = number_of_shards
15
15
  @test_partitions = test_partitions.map(&:to_set)
16
16
  @relevant_partitions = test_partitions.flatten.to_set
17
17
  @stats = ResultStats.new
18
- ResultStats.members.each { |k| @stats[k] = 0}
18
+ @options_for_metapartition = options_for_metapartition.map { |o| Options::DEFAULT.merge(o) }
19
+ @allow_fewer_shards = allow_fewer_shards
20
+ ResultStats.members.each { |k| @stats[k] = 0 }
19
21
  end
20
22
 
21
- PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count
23
+ PartitionWithMachines = Struct.new :test_time_map, :number_of_shards, :partition_time, :max_shard_count, :options
22
24
  MachineAssignment = Struct.new :test_time_map, :total_time
23
25
  ResultStats = Struct.new :historical_total_tests, :current_total_tests, :class_extrapolations, :target_extrapolations
26
+ Options = Struct.new :max_shard_count, :split_bundles_across_machines, :allow_fewer_shards do
27
+ def merge(hash)
28
+ self.class.new(*to_h.merge(hash).values_at(*members))
29
+ end
30
+ end
31
+ Options::DEFAULT = Options.new(nil, true, false).freeze
24
32
 
25
33
  class PartitionResult
26
34
  TimeImbalances = Struct.new :partition_set, :partitions
27
- attr_reader :stats, :test_maps, :test_times, :total_test_time, :test_time_imbalances
35
+ attr_reader :stats, :test_maps, :test_times, :total_test_time, :test_time_imbalances, :test_time_for_partitions
28
36
  extend Forwardable
29
37
  delegate ResultStats.members => :@stats
30
38
 
31
- def initialize(stats, partition_sets)
39
+ def initialize(stats, partition_sets, test_time_for_partitions)
32
40
  @stats = stats
33
41
  @partition_sets = partition_sets
34
42
  @test_maps = partition_sets_map(&:test_time_map)
35
43
  @test_times = partition_sets_map(&:total_time)
36
44
  @total_test_time = test_times.flatten.inject(:+)
37
45
  @test_time_imbalances = compute_test_time_imbalances
46
+ @test_time_for_partitions = test_time_for_partitions.inject(&:merge)
38
47
  end
39
48
 
40
49
  private
@@ -74,8 +83,8 @@ module XCKnife
74
83
 
75
84
  def compute_shards_for_partitions(test_time_for_partitions)
76
85
  PartitionResult.new(@stats, split_machines_proportionally(test_time_for_partitions).map do |partition|
77
- compute_single_shards(partition.number_of_shards, partition.test_time_map)
78
- end)
86
+ compute_single_shards(partition.number_of_shards, partition.test_time_map, options: partition.options)
87
+ end, test_time_for_partitions)
79
88
  end
80
89
 
81
90
  def test_time_for_partitions(historical_events, current_events = nil)
@@ -101,18 +110,21 @@ module XCKnife
101
110
 
102
111
  used_shards = 0
103
112
  assignable_shards = number_of_shards - partitions.size
104
- partition_with_machines_list = partitions.map do |test_time_map|
113
+ partition_with_machines_list = partitions.each_with_index.map do |test_time_map, metapartition|
114
+ options = @options_for_metapartition[metapartition]
105
115
  partition_time = 0
106
- max_shard_count = test_time_map.values.map(&:size).inject(:+) || 1
107
- each_duration(test_time_map) { |duration_in_milliseconds| partition_time += duration_in_milliseconds}
116
+ max_shard_count = test_time_map.each_value.map(&:size).reduce(&:+) || 1
117
+ max_shard_count = [max_shard_count, options.max_shard_count].min if options.max_shard_count
118
+ each_duration(test_time_map) { |duration_in_milliseconds| partition_time += duration_in_milliseconds }
108
119
  n = [1 + (assignable_shards * partition_time.to_f / total).floor, max_shard_count].min
109
120
  used_shards += n
110
- PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count)
121
+ PartitionWithMachines.new(test_time_map, n, partition_time, max_shard_count, options)
111
122
  end
112
123
 
113
124
  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)
114
- while (number_of_shards - used_shards) > 0
125
+ while number_of_shards > used_shards
115
126
  if fifo_with_machines_who_can_use_more_shards.empty?
127
+ break if @allow_fewer_shards
116
128
  raise XCKnife::XCKnifeError, "There are #{number_of_shards - used_shards} extra machines"
117
129
  end
118
130
  machine = fifo_with_machines_who_can_use_more_shards.pop
@@ -125,9 +137,9 @@ module XCKnife
125
137
  partition_with_machines_list
126
138
  end
127
139
 
128
- # Computes a 2-aproximation to the optimal partition_time, which is an instance of the Open shop scheduling problem (which is NP-hard)
140
+ # Computes a 2-aproximation to the optimal partition_time, which is an instance of the Open shop scheduling problem (which is NP-hard)
129
141
  # see: https://en.wikipedia.org/wiki/Open-shop_scheduling
130
- def compute_single_shards(number_of_shards, test_time_map)
142
+ def compute_single_shards(number_of_shards, test_time_map, options: Options::DEFAULT)
131
143
  raise XCKnife::XCKnifeError, "There are not enough workers provided" if number_of_shards <= 0
132
144
  raise XCKnife::XCKnifeError, "Cannot shard an empty partition_time" if test_time_map.empty?
133
145
  assignements = Array.new(number_of_shards) { MachineAssignment.new(Hash.new { |k, v| k[v] = [] }, 0) }
@@ -139,13 +151,36 @@ module XCKnife
139
151
  end
140
152
  end
141
153
 
142
- list_of_test_target_class_times.sort_by! { |test_target, class_name, duration_in_milliseconds| -duration_in_milliseconds }
143
- list_of_test_target_class_times.each do |test_target, class_name, duration_in_milliseconds|
154
+ # This might seem like an uncessary level of indirection, but it allows us to keep
155
+ # logic consistent regardless of the `split_bundles_across_machines` option
156
+ list_of_test_target_classes_times = list_of_test_target_class_times.group_by do |test_target, class_name, duration_in_milliseconds|
157
+ if options.split_bundles_across_machines
158
+ [test_target, class_name]
159
+ else
160
+ test_target
161
+ end
162
+ end.map do |(test_target, _), classes|
163
+ [
164
+ test_target,
165
+ classes.map { |test_target, class_name, duration_in_milliseconds| class_name },
166
+ classes.reduce(0) { |total_duration, (test_target, class_name, duration_in_milliseconds)| total_duration + duration_in_milliseconds},
167
+ ]
168
+ end
169
+
170
+ list_of_test_target_classes_times.sort_by! { |test_target, class_names, duration_in_milliseconds| -duration_in_milliseconds }
171
+ list_of_test_target_classes_times.each do |test_target, class_names, duration_in_milliseconds|
144
172
  assignemnt = assignements.min_by(&:total_time)
145
- assignemnt.test_time_map[test_target] << class_name
173
+ assignemnt.test_time_map[test_target].concat class_names
146
174
  assignemnt.total_time += duration_in_milliseconds
147
175
  end
148
- raise XCKnife::XCKnifeError, "Too many shards" if assignements.any? { |a| a.test_time_map.empty? }
176
+
177
+ if (empty_test_map_assignments = assignements.select { |a| a.test_time_map.empty? }) && !empty_test_map_assignments.empty? && !options.allow_fewer_shards
178
+ test_grouping = options.split_bundles_across_machines ? 'classes' : 'targets'
179
+ raise XCKnife::XCKnifeError, "Too many shards -- #{empty_test_map_assignments.size} of #{number_of_shards} assignments are empty," \
180
+ " because there are not enough test #{test_grouping} for that many shards."
181
+ end
182
+ assignements.reject! { |a| a.test_time_map.empty? }
183
+
149
184
  assignements
150
185
  end
151
186
 
@@ -4,6 +4,10 @@ require 'fileutils'
4
4
  require 'tmpdir'
5
5
  require 'ostruct'
6
6
  require 'set'
7
+ require 'logger'
8
+ require 'shellwords'
9
+ require 'open3'
10
+ require 'xcknife/exceptions'
7
11
 
8
12
  module XCKnife
9
13
  class TestDumper
@@ -11,32 +15,125 @@ module XCKnife
11
15
  new(ARGV).run
12
16
  end
13
17
 
14
- def initialize(args)
15
- @derived_data_folder, @output_file, @device_id = args
18
+ attr_reader :logger
19
+
20
+ def initialize(args, logger: Logger.new($stdout, progname: 'xcknife test dumper'))
21
+ @debug = false
22
+ @max_retry_count = 150
23
+ @temporary_output_folder = nil
24
+ @xcscheme_file = nil
25
+ @parser = build_parser
26
+ @naive_dump_bundle_names = []
27
+ @skip_dump_bundle_names = []
28
+ parse_arguments(args)
16
29
  @device_id ||= "booted"
30
+ @logger = logger
31
+ @logger.level = @debug ? Logger::DEBUG : Logger::FATAL
32
+ @parser = nil
33
+ @simctl_timeout = 0
17
34
  end
18
35
 
19
36
  def run
20
- if @derived_data_folder.nil? or @output_file.nil?
21
- return puts "Usage: xcknife-test-dumper [derived_data_folder] [output_file] [<device_id>]"
22
- end
23
- helper = TestDumperHelper.new(@device_id)
37
+ helper = TestDumperHelper.new(@device_id, @max_retry_count, @debug, @logger, @dylib_logfile_path,
38
+ naive_dump_bundle_names: @naive_dump_bundle_names, skip_dump_bundle_names: @skip_dump_bundle_names, simctl_timeout: @simctl_timeout)
39
+ extra_environment_variables = parse_scheme_file
40
+ logger.info { "Environment variables from xcscheme: #{extra_environment_variables.pretty_inspect}" }
24
41
  output_fd = File.open(@output_file, "w")
25
- Dir.mktmpdir("xctestdumper_") do |outfolder|
26
- helper.call(@derived_data_folder, outfolder).each do |test_specification|
27
- concat_to_file(test_specification, output_fd)
42
+ if @temporary_output_folder.nil?
43
+ Dir.mktmpdir("xctestdumper_") do |outfolder|
44
+ list_tests(extra_environment_variables, helper, outfolder, output_fd)
45
+ end
46
+ else
47
+ unless File.directory?(@temporary_output_folder)
48
+ raise TestDumpError, "Error no such directory: #{@temporary_output_folder}"
49
+ end
50
+
51
+ if Dir.entries(@temporary_output_folder).any? { |f| File.file?(File.join(@temporary_output_folder,f)) }
52
+ puts "Warning: #{@temporary_output_folder} is not empty! Files can be overwritten."
28
53
  end
54
+ list_tests(extra_environment_variables, helper, File.absolute_path(@temporary_output_folder), output_fd)
29
55
  end
30
56
  output_fd.close
31
57
  puts "Done listing test methods"
32
58
  end
33
59
 
34
60
  private
61
+ def list_tests(extra_environment_variables, helper, outfolder, output_fd)
62
+ helper.call(@derived_data_folder, outfolder, extra_environment_variables).each do |test_specification|
63
+ concat_to_file(test_specification, output_fd)
64
+ end
65
+ end
66
+
67
+
68
+ def parse_scheme_file
69
+ return {} unless @xcscheme_file
70
+ unless File.exists?(@xcscheme_file)
71
+ raise ArgumentError, "Error: no such xcscheme file: #{@xcscheme_file}"
72
+ end
73
+ XCKnife::XcschemeAnalyzer.extract_environment_variables(IO.read(@xcscheme_file))
74
+ end
75
+
76
+ def parse_arguments(args)
77
+ positional_arguments = parse_options(args)
78
+ if positional_arguments.size < required_arguments.size
79
+ warn_and_exit("You must specify *all* required arguments: #{required_arguments.join(", ")}")
80
+ end
81
+ @derived_data_folder, @output_file, @device_id = positional_arguments
82
+ end
83
+
84
+ def parse_options(args)
85
+ begin
86
+ return @parser.parse(args)
87
+ rescue OptionParser::ParseError => error
88
+ warn_and_exit(error)
89
+ end
90
+ end
91
+
92
+ def build_parser
93
+ OptionParser.new do |opts|
94
+ opts.banner += " #{arguments_banner}"
95
+ opts.on("-d", "--debug", "Debug mode enabled") { |v| @debug = v }
96
+ opts.on("-r", "--retry-count COUNT", "Max retry count for simulator output", Integer) { |v| @max_retry_count = v }
97
+ opts.on("-x", '--simctl-timeout SECONDS', "Max allowed time in seconds for simctl commands", Integer) { |v| @simctl_timeout = v }
98
+ opts.on("-t", "--temporary-output OUTPUT_FOLDER", "Sets temporary Output folder") { |v| @temporary_output_folder = v }
99
+ opts.on("-s", "--scheme XCSCHEME_FILE", "Reads environments variables from the xcscheme file") { |v| @xcscheme_file = v }
100
+ opts.on("-l", "--dylib_logfile DYLIB_LOG_FILE", "Path for dylib log file") { |v| @dylib_logfile_path = v }
101
+ opts.on('--naive-dump TEST_BUNDLE_NAMES', 'List of test bundles to dump using static analysis', Array) { |v| @naive_dump_bundle_names = v }
102
+ opts.on('--skip-dump TEST_BUNDLE_NAMES', 'List of test bundles to skip dumping', Array) { |v| @skip_dump_bundle_names = v }
103
+
104
+ opts.on_tail("-h", "--help", "Show this message") do
105
+ puts opts
106
+ exit
107
+ end
108
+ end
109
+ end
110
+
111
+ def required_arguments
112
+ %w[derived_data_folder output_file]
113
+ end
114
+
115
+ def optional_arguments
116
+ %w[device_id simctl_timeout]
117
+ end
118
+
119
+ def arguments_banner
120
+ optional_args = optional_arguments.map { |a| "[#{a}]" }
121
+ (required_arguments + optional_args).join(" ")
122
+ end
123
+
124
+ def warn_and_exit(msg)
125
+ raise TestDumpError, "#{msg.to_s.capitalize} \n\n#{@parser}"
126
+ end
127
+
35
128
  def concat_to_file(test_specification, output_fd)
36
129
  file = test_specification.json_stream_file
37
130
  IO.readlines(file).each do |line|
38
131
  event = OpenStruct.new(JSON.load(line))
39
- output_fd.write(line) unless should_test_event_be_ignored?(test_specification, event)
132
+ if should_test_event_be_ignored?(test_specification, event)
133
+ logger.info "Skipped test dumper line #{line}"
134
+ else
135
+ output_fd.write(line)
136
+ end
40
137
  output_fd.flush
41
138
  end
42
139
  output_fd.flush
@@ -52,48 +149,74 @@ module XCKnife
52
149
  class TestDumperHelper
53
150
  TestSpecification = Struct.new :json_stream_file, :skip_test_identifiers
54
151
 
55
- def initialize(device_id)
152
+ attr_reader :logger
153
+
154
+ def initialize(device_id, max_retry_count, debug, logger, dylib_logfile_path,
155
+ naive_dump_bundle_names: [], skip_dump_bundle_names: [], simctl_timeout: 0)
56
156
  @xcode_path = `xcode-select -p`.strip
57
157
  @simctl_path = `xcrun -f simctl`.strip
58
- @platforms_path = "#{@xcode_path}/Platforms/"
59
- @platform_path = "#{@platforms_path}/iPhoneSimulator.platform"
60
- @sdk_path = "#{@platform_path}/Developer/SDKs/iPhoneSimulator.sdk"
158
+ @nm_path = `xcrun -f nm`.strip
159
+ @swift_path = `xcrun -f swift`.strip
160
+ @platforms_path = File.join(@xcode_path, "Platforms")
161
+ @platform_path = File.join(@platforms_path, "iPhoneSimulator.platform")
162
+ @sdk_path = File.join(@platform_path, "Developer/SDKs/iPhoneSimulator.sdk")
61
163
  @testroot = nil
62
164
  @device_id = device_id
165
+ @max_retry_count = max_retry_count
166
+ @simctl_timeout = simctl_timeout
167
+ @logger = logger
168
+ @debug = debug
169
+ @dylib_logfile_path = dylib_logfile_path if dylib_logfile_path
170
+ @naive_dump_bundle_names = naive_dump_bundle_names
171
+ @skip_dump_bundle_names = skip_dump_bundle_names
63
172
  end
64
173
 
65
- def call(derived_data_folder, list_folder)
66
- @testroot = "#{derived_data_folder}/Build/Products/"
67
- xctestrun_file = Dir["#{@testroot}/*.xctestrun"].first
174
+ def call(derived_data_folder, list_folder, extra_environment_variables = {})
175
+ @testroot = File.join(derived_data_folder, 'Build', 'Products')
176
+ xctestrun_file = Dir[File.join(@testroot, '*.xctestrun')].first
68
177
  if xctestrun_file.nil?
69
- puts "No xctestrun on #{@testroot}"
70
- exit 1
178
+ raise ArgumentError, "No xctestrun on #{@testroot}"
71
179
  end
72
180
  xctestrun_as_json = `plutil -convert json -o - "#{xctestrun_file}"`
73
181
  FileUtils.mkdir_p(list_folder)
74
- JSON.load(xctestrun_as_json).map do |test_bundle_name, test_bundle|
75
- test_specification = list_tests_wiht_simctl(list_folder, test_bundle, test_bundle_name)
76
- wait_test_dumper_completion(test_specification.json_stream_file)
182
+ list_tests(JSON.load(xctestrun_as_json), list_folder, extra_environment_variables)
183
+ end
184
+
185
+ private
186
+
187
+ attr_reader :testroot
188
+
189
+ def list_tests(xctestrun, list_folder, extra_environment_variables)
190
+ xctestrun.reject! { |test_bundle_name, _| test_bundle_name == '__xctestrun_metadata__' }
191
+ xctestrun.map do |test_bundle_name, test_bundle|
192
+ if @skip_dump_bundle_names.include?(test_bundle_name)
193
+ logger.info { "Skipping dumping tests in `#{test_bundle_name}` -- writing out fake event"}
194
+ test_specification = list_single_test(list_folder, test_bundle, test_bundle_name)
195
+ elsif @naive_dump_bundle_names.include?(test_bundle_name)
196
+ test_specification = list_tests_with_nm(list_folder, test_bundle, test_bundle_name)
197
+ else
198
+ test_specification = list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
199
+ wait_test_dumper_completion(test_specification.json_stream_file)
200
+ end
201
+
77
202
  test_specification
78
203
  end
79
204
  end
80
205
 
81
- private
82
- def list_tests_wiht_simctl(list_folder, test_bundle, test_bundle_name)
206
+ def list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
83
207
  env_variables = test_bundle["EnvironmentVariables"]
84
208
  testing_env_variables = test_bundle["TestingEnvironmentVariables"]
85
- outpath = "#{list_folder}/#{test_bundle_name}"
209
+ outpath = File.join(list_folder, test_bundle_name)
86
210
  test_host = replace_vars(test_bundle["TestHostPath"])
87
211
  test_bundle_path = replace_vars(test_bundle["TestBundlePath"], test_host)
88
212
  test_dumper_path = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'TestDumper', 'TestDumper.dylib'))
89
213
  unless File.exist?(test_dumper_path)
90
- warn "Could not find TestDumpber.dylib on #{test_dumper_path}"
91
- exit 1
214
+ raise TestDumpError, "Could not find TestDumper.dylib on #{test_dumper_path}"
92
215
  end
93
216
 
94
217
  is_logic_test = test_bundle["TestHostBundleIdentifier"].nil?
95
218
  env = simctl_child_attrs(
96
- "XCTEST_TYPE" => is_logic_test ? "LOGICTEST" : "APPTEST",
219
+ "XCTEST_TYPE" => xctest_type(test_bundle),
97
220
  "XCTEST_TARGET" => test_bundle_name,
98
221
  "TestDumperOutputPath" => outpath,
99
222
  "IDE_INJECTION_PATH" => testing_env_variables["DYLD_INSERT_LIBRARIES"],
@@ -110,7 +233,10 @@ module XCKnife
110
233
  "DYLD_FALLBACK_FRAMEWORK_PATH" => "#{@platform_path}/Developer/Library/Frameworks",
111
234
  "DYLD_INSERT_LIBRARIES" => test_dumper_path,
112
235
  )
236
+ env.merge!(simctl_child_attrs(extra_environment_variables))
113
237
  inject_vars(env, test_host)
238
+ FileUtils.rm_f(outpath)
239
+ logger.info { "Temporary TestDumper file for #{test_bundle_name} is #{outpath}" }
114
240
  if is_logic_test
115
241
  run_logic_test(env, test_host, test_bundle_path)
116
242
  else
@@ -121,6 +247,46 @@ module XCKnife
121
247
  return TestSpecification.new outpath, discover_tests_to_skip(test_bundle)
122
248
  end
123
249
 
250
+ def list_tests_with_nm(list_folder, test_bundle, test_bundle_name)
251
+ output_methods(list_folder, test_bundle, test_bundle_name) do |test_bundle_path|
252
+ methods = []
253
+ swift_demangled_nm(test_bundle_path) do |output|
254
+ output.each_line do |line|
255
+ next unless method = method_from_nm_line(line)
256
+ methods << method
257
+ end
258
+ end
259
+ methods
260
+ end
261
+ end
262
+
263
+ def list_single_test(list_folder, test_bundle, test_bundle_name)
264
+ output_methods(list_folder, test_bundle, test_bundle_name) do
265
+ [{ class: test_bundle_name, method: 'test' }]
266
+ end
267
+ end
268
+
269
+ def output_methods(list_folder, test_bundle, test_bundle_name)
270
+ outpath = File.join(list_folder, test_bundle_name)
271
+ logger.info { "Writing out TestDumper file for #{test_bundle_name} to #{outpath}" }
272
+ test_specification = TestSpecification.new outpath, discover_tests_to_skip(test_bundle)
273
+
274
+ test_bundle_path = replace_vars(test_bundle["TestBundlePath"], replace_vars(test_bundle["TestHostPath"]))
275
+ methods = yield(test_bundle_path)
276
+
277
+ test_type = xctest_type(test_bundle)
278
+ File.open test_specification.json_stream_file, 'a' do |f|
279
+ f << JSON.dump(message: "Starting Test Dumper", event: "begin-test-suite", testType: test_type) << "\n"
280
+ f << JSON.dump(event: 'begin-ocunit', bundleName: File.basename(test_bundle_path), targetName: test_bundle_name) << "\n"
281
+ methods.map { |method| method[:class] }.uniq.each do |class_name|
282
+ f << JSON.dump(test: '1', className: class_name, event: "end-test", totalDuration: "0") << "\n"
283
+ end
284
+ f << JSON.dump(message: "Completed Test Dumper", event: "end-action", testType: test_type) << "\n"
285
+ end
286
+
287
+ test_specification
288
+ end
289
+
124
290
  def discover_tests_to_skip(test_bundle)
125
291
  identifier_for_test_method = "/"
126
292
  skip_test_identifiers = test_bundle["SkipTestIdentifiers"] || []
@@ -131,10 +297,31 @@ module XCKnife
131
297
  @simctl_path
132
298
  end
133
299
 
300
+ def wrapped_simctl(args)
301
+ args = [*gtimeout, simctl] + args
302
+ args
303
+ end
304
+
305
+ def gtimeout
306
+ return [] unless @simctl_timeout > 0
307
+
308
+ path = gtimeout_path
309
+ if path.empty?
310
+ puts "warning: simctl_timeout specified but 'gtimeout' is not installed. The specified timeout will be ignored."
311
+ return []
312
+ end
313
+
314
+ [path, "-k", "5", "#{@simctl_timeout}"]
315
+ end
316
+
317
+ def gtimeout_path
318
+ `which gtimeout`.strip
319
+ end
320
+
134
321
  def replace_vars(str, testhost = "<UNKNOWN>")
135
322
  str.gsub("__PLATFORMS__", @platforms_path).
136
323
  gsub("__TESTHOST__", testhost).
137
- gsub("__TESTROOT__", @testroot)
324
+ gsub("__TESTROOT__", testroot)
138
325
  end
139
326
 
140
327
  def inject_vars(env, test_host)
@@ -150,47 +337,96 @@ module XCKnife
150
337
  end
151
338
 
152
339
  def install_app(test_host_path)
153
- until system("#{simctl} install #{@device_id} '#{test_host_path}'")
154
- sleep 0.1
340
+ retries_count = 0
341
+ max_retry_count = 3
342
+ until (retries_count > max_retry_count) or call_simctl(["install", @device_id, test_host_path])
343
+ retries_count += 1
344
+ call_simctl ['shutdown', @device_id]
345
+ call_simctl ['boot', @device_id]
346
+ sleep 1.0
347
+ end
348
+
349
+ if retries_count > max_retry_count
350
+ raise TestDumpError, "Installing #{test_host_path} failed"
155
351
  end
352
+
156
353
  end
157
354
 
158
355
  def wait_test_dumper_completion(file)
159
356
  retries_count = 0
160
357
  until has_test_dumper_terminated?(file) do
161
358
  retries_count += 1
162
- assert_has_not_timed_out(retries_count, file)
359
+ if retries_count == @max_retry_count
360
+ raise TestDumpError, "Timeout error on: #{file}"
361
+ end
163
362
  sleep 0.1
164
363
  end
165
364
  end
166
365
 
167
- def assert_has_not_timed_out(retries_count, file)
168
- if retries_count == 100
169
- puts "Timeout error on: #{file}"
170
- exit 1
171
- end
172
- end
173
-
174
366
  def has_test_dumper_terminated?(file)
175
367
  return false unless File.exists?(file)
176
368
  last_line = `tail -n 1 "#{file}"`
177
- return /Completed Test Dumper/.match(last_line)
369
+ return last_line.include?("Completed Test Dumper")
178
370
  end
179
371
 
180
372
  def run_apptest(env, test_host_bundle_identifier, test_bundle_path)
181
- call_simctl env, "launch #{@device_id} '#{test_host_bundle_identifier}' -XCTest All '#{test_bundle_path}'"
373
+ unless call_simctl(["launch", @device_id, test_host_bundle_identifier, '-XCTest', 'All', dylib_logfile_path, test_bundle_path], env: env)
374
+ raise TestDumpError, "Launching #{test_bundle_path} in #{test_host_bundle_identifier} failed"
375
+ end
182
376
  end
183
377
 
184
378
  def run_logic_test(env, test_host, test_bundle_path)
185
- call_simctl env, "spawn #{@device_id} '#{test_host}' -XCTest All '#{test_bundle_path}' 2> /dev/null"
379
+ opts = @debug ? {} : { err: "/dev/null" }
380
+ unless call_simctl(["spawn", @device_id, test_host, '-XCTest', 'All', dylib_logfile_path, test_bundle_path], env: env, **opts)
381
+ raise TestDumpError, "Spawning #{test_bundle_path} in #{test_host} failed"
382
+ end
186
383
  end
187
384
 
188
- def call_simctl(env, string_args)
189
- cmd = "#{simctl} #{string_args}"
385
+ def call_simctl(args, env: {}, **spawn_opts)
386
+ args = wrapped_simctl(args)
387
+ cmd = Shellwords.shelljoin(args)
190
388
  puts "Running:\n$ #{cmd}"
191
- unless system(env, cmd)
192
- puts "Simctl errored with the following env:\n #{env.pretty_print_inspect}"
389
+ logger.info { "Environment variables:\n #{env.pretty_print_inspect}" }
390
+
391
+ ret = system(env, *args, **spawn_opts)
392
+ puts "Simctl errored with the following env:\n #{env.pretty_print_inspect}" unless ret
393
+ ret
394
+ end
395
+
396
+ def dylib_logfile_path
397
+ @dylib_logfile_path ||= '/tmp/xcknife_testdumper_dylib.log'
398
+ end
399
+
400
+ def xctest_type(test_bundle)
401
+ if test_bundle["TestHostBundleIdentifier"].nil?
402
+ "LOGICTEST"
403
+ else
404
+ "APPTEST"
193
405
  end
194
406
  end
407
+
408
+ def swift_demangled_nm(test_bundle_path)
409
+ Open3.pipeline_r([@nm_path, File.join(test_bundle_path, File.basename(test_bundle_path, '.xctest'))], [@swift_path, 'demangle']) do |o, _ts|
410
+ yield(o)
411
+ end
412
+ end
413
+
414
+ def method_from_nm_line(line)
415
+ return unless line.strip =~ %r{^
416
+ [\da-f]+\s # address
417
+ [tT]\s # symbol type
418
+ (?: # method
419
+ -\[(.+)\s(test.+)\] # objc instance method
420
+ | # or swift instance method
421
+ _? # only present on Xcode 10.0 and below
422
+ (?:@objc\s)? # optional objc annotation
423
+ (?:[^\.]+\.)? # module name
424
+ (.+) # class name
425
+ \.(test.+)\s->\s\(\) # method signature
426
+ )
427
+ $}ox
428
+
429
+ { class: $1 || $3, method: $2 || $4 }
430
+ end
195
431
  end
196
432
  end