xcknife 0.6.3 → 0.11.0

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