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.
- checksums.yaml +5 -5
- data/.gitignore +4 -1
- data/.rubocop.yml +168 -0
- data/.ruby-version +1 -1
- data/.travis.yml +1 -5
- data/.vscode/configure.sh +23 -0
- data/.vscode/vscode_ruby.json.template +44 -0
- data/Gemfile +10 -2
- data/Gemfile.lock +63 -0
- data/OWNERS.yml +2 -0
- data/README.md +9 -1
- data/Rakefile +15 -11
- data/TestDumper/README.md +2 -1
- 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 +83 -44
- data/bin/xcknife +2 -0
- data/bin/xcknife-min +12 -15
- data/bin/xcknife-test-dumper +2 -0
- data/example/run_example.rb +10 -8
- data/example/xcknife-exemplar-historical-data.json-stream +3 -3
- data/example/xcknife-exemplar.json-stream +3 -3
- data/lib/xcknife.rb +3 -1
- data/lib/xcknife/events_analyzer.rb +11 -6
- data/lib/xcknife/exceptions.rb +6 -2
- data/lib/xcknife/json_stream_parser_helper.rb +8 -8
- data/lib/xcknife/runner.rb +18 -19
- data/lib/xcknife/stream_parser.rb +77 -34
- data/lib/xcknife/test_dumper.rb +283 -112
- data/lib/xcknife/xcscheme_analyzer.rb +9 -9
- data/lib/xcknife/xctool_cmd_helper.rb +28 -4
- data/xcknife.gemspec +8 -9
- metadata +14 -11
- data/.gitmodules +0 -3
data/lib/xcknife.rb
CHANGED
@@ -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.
|
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
|
21
|
-
target_class_map.
|
23
|
+
def test_target?(target)
|
24
|
+
target_class_map.key?(target)
|
22
25
|
end
|
23
26
|
|
24
|
-
def
|
25
|
-
|
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
|
48
|
+
def test_target?(_target)
|
44
49
|
true
|
45
50
|
end
|
46
51
|
|
47
|
-
def
|
52
|
+
def test_class?(_target, _clazz)
|
48
53
|
true
|
49
54
|
end
|
50
55
|
|
data/lib/xcknife/exceptions.rb
CHANGED
@@ -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(
|
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 ==
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
data/lib/xcknife/runner.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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 =>
|
105
|
-
warn_and_exit(
|
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(
|
113
|
-
|
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(
|
118
|
-
opts.on(
|
119
|
-
opts.on(
|
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(
|
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
|
-
|
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 =
|
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.
|
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.
|
108
|
-
|
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
|
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,
|
133
|
-
raise XCKnife::XCKnifeError,
|
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
|
-
|
144
|
-
|
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]
|
178
|
+
assignemnt.test_time_map[test_target].concat class_names
|
147
179
|
assignemnt.total_time += duration_in_milliseconds
|
148
180
|
end
|
149
|
-
|
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.
|
195
|
+
return [] unless File.exist?(filename)
|
196
|
+
|
156
197
|
lines = IO.readlines(filename)
|
157
|
-
lines.lazy.map { |line| OpenStruct.new(JSON.
|
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
|
166
|
-
test_time_map.each do |
|
167
|
-
class_times.each do |
|
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.
|
225
|
+
if times_for_target_class.key?(test_target)
|
183
226
|
class_set.each do |clazz|
|
184
|
-
unless times_for_target_class[test_target].
|
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
|
data/lib/xcknife/test_dumper.rb
CHANGED
@@ -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 ||=
|
25
|
-
@logger =
|
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,
|
44
|
+
output_fd = File.open(@output_file, 'w')
|
35
45
|
if @temporary_output_folder.nil?
|
36
|
-
Dir.mktmpdir(
|
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
|
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.
|
65
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
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(
|
91
|
-
opts.on(
|
92
|
-
opts.on(
|
93
|
-
opts.on(
|
94
|
-
|
95
|
-
opts.
|
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
|
-
|
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.
|
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[
|
137
|
-
|
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
|
-
|
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
|
-
@
|
150
|
-
@
|
151
|
-
@
|
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 =
|
161
|
-
xctestrun_file = Dir[
|
162
|
-
if xctestrun_file.nil?
|
163
|
-
|
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.
|
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
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
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[
|
254
|
+
is_logic_test = test_bundle['TestHostBundleIdentifier'].nil?
|
189
255
|
env = simctl_child_attrs(
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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.
|
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[
|
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
|
-
|
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[
|
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
|
232
|
-
|
233
|
-
|
234
|
-
|
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 ||
|
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
|
-
|
251
|
-
|
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
|
393
|
+
until test_dumper_terminated?(file)
|
258
394
|
retries_count += 1
|
259
|
-
if retries_count == @max_retry_count
|
260
|
-
|
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
|
268
|
-
return false unless File.
|
401
|
+
def test_dumper_terminated?(file)
|
402
|
+
return false unless File.exist?(file)
|
403
|
+
|
269
404
|
last_line = `tail -n 1 "#{file}"`
|
270
|
-
|
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
|
-
|
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
|
-
|
279
|
-
|
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
|
-
|
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,
|
287
|
-
|
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
|
-
|
291
|
-
|
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
|