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.
- checksums.yaml +5 -5
- data/.gitignore +2 -1
- data/.ruby-version +1 -1
- data/.travis.yml +12 -7
- data/Gemfile.lock +35 -0
- data/OWNERS.yml +2 -0
- data/README.md +13 -4
- data/Rakefile +3 -3
- data/TestDumper/README.md +16 -2
- data/TestDumper/TestDumper.xcodeproj/project.pbxproj +27 -5
- data/TestDumper/TestDumper.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- data/TestDumper/TestDumper.xcodeproj/xcshareddata/xcschemes/TestDumper.xcscheme +1 -1
- data/TestDumper/TestDumper/Initialize.m +94 -41
- data/TestDumper/build.sh +3 -2
- data/example/run_example.rb +1 -0
- data/lib/xcknife.rb +2 -1
- data/lib/xcknife/exceptions.rb +4 -2
- data/lib/xcknife/stream_parser.rb +53 -18
- data/lib/xcknife/test_dumper.rb +282 -46
- data/lib/xcknife/xcscheme_analyzer.rb +29 -0
- data/lib/xcknife/xctool_cmd_helper.rb +29 -4
- data/xcknife.gemspec +0 -3
- metadata +8 -7
- data/.gitmodules +0 -3
data/TestDumper/build.sh
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
#!/bin/bash
|
2
|
+
rm -rf TestDumper.dylib testdumperbuild
|
2
3
|
xcodebuild -project TestDumper.xcodeproj \
|
3
|
-
-configuration
|
4
|
+
-configuration Debug \
|
4
5
|
-derivedDataPath testdumperbuild \
|
5
6
|
-scheme TestDumper \
|
6
|
-
-sdk iphonesimulator build
|
7
|
+
-sdk iphonesimulator build ONLY_ACTIVE_ARCH=NO
|
data/example/run_example.rb
CHANGED
@@ -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
|
data/lib/xcknife.rb
CHANGED
data/lib/xcknife/exceptions.rb
CHANGED
@@ -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
|
-
|
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.
|
107
|
-
|
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
|
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
|
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
|
-
|
143
|
-
|
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]
|
173
|
+
assignemnt.test_time_map[test_target].concat class_names
|
146
174
|
assignemnt.total_time += duration_in_milliseconds
|
147
175
|
end
|
148
|
-
|
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
|
|
data/lib/xcknife/test_dumper.rb
CHANGED
@@ -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
|
-
|
15
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
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
|
-
|
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
|
-
@
|
59
|
-
@
|
60
|
-
@
|
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 =
|
67
|
-
xctestrun_file = Dir[
|
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
|
-
|
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)
|
75
|
-
|
76
|
-
|
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
|
-
|
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 =
|
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
|
-
|
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" =>
|
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__",
|
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
|
-
|
154
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
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,
|
189
|
-
|
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
|
-
|
192
|
-
|
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
|