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