fastlane-plugin-test_center 3.6.2 → 3.6.3.parallelizing

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.
Files changed (22) hide show
  1. checksums.yaml +5 -5
  2. data/lib/fastlane/plugin/test_center.rb +1 -1
  3. data/lib/fastlane/plugin/test_center/actions/collate_test_result_bundles.rb +1 -1
  4. data/lib/fastlane/plugin/test_center/actions/multi_scan.rb +28 -4
  5. data/lib/fastlane/plugin/test_center/actions/restart_core_simulator_service.rb +37 -0
  6. data/lib/fastlane/plugin/test_center/actions/tests_from_xctestrun.rb +1 -1
  7. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager.rb +5 -0
  8. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/device_manager.rb +26 -0
  9. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/interstitial.rb +143 -0
  10. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/report_collator.rb +113 -0
  11. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/retrying_scan.rb +72 -0
  12. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/retrying_scan_helper.rb +236 -0
  13. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/runner.rb +272 -0
  14. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/simulator_helper.rb +59 -0
  15. data/lib/fastlane/plugin/test_center/helper/multi_scan_manager/simulator_manager.rb +317 -0
  16. data/lib/fastlane/plugin/test_center/helper/reportname_helper.rb +15 -6
  17. data/lib/fastlane/plugin/test_center/helper/test_collector.rb +47 -3
  18. data/lib/fastlane/plugin/test_center/helper/xcodebuild_string.rb +9 -0
  19. data/lib/fastlane/plugin/test_center/helper/xctestrun_info.rb +42 -0
  20. data/lib/fastlane/plugin/test_center/version.rb +1 -1
  21. metadata +21 -12
  22. data/lib/fastlane/plugin/test_center/helper/correcting_scan_helper.rb +0 -293
@@ -0,0 +1,59 @@
1
+ module TestCenter
2
+ module Helper
3
+ module MultiScanManager
4
+ class SimulatorHelper
5
+ def initialize(options)
6
+ @options = options
7
+ end
8
+
9
+ def setup
10
+ if @options[:parallelize]
11
+ setup_scan_config
12
+ delete_multi_scan_cloned_simulators
13
+ end
14
+ end
15
+
16
+ def setup_scan_config
17
+ unless ::Scan.config&._values.has_key?(:destination)
18
+ scan_option_keys = Scan.config.all_keys
19
+ ::Scan.config = FastlaneCore::Configuration.create(
20
+ Fastlane::Actions::ScanAction.available_options,
21
+ @options.select{ |k| scan_option_keys.include?(k) }
22
+ )
23
+ end
24
+ end
25
+
26
+ def clone_destination_simulators
27
+ cloned_simulators = []
28
+
29
+ batch_count = @options[:batch_count] || 0
30
+ destination_simulator_ids = Scan.config[:destination].map do |destination|
31
+ destination.split(',id=').last
32
+ end
33
+ original_simulators = FastlaneCore::DeviceManager.simulators('iOS').find_all do |simulator|
34
+ destination_simulator_ids.include?(simulator.udid)
35
+ end
36
+ (0...batch_count).each do |batch_index|
37
+ cloned_simulators << []
38
+ original_simulators.each do |simulator|
39
+ FastlaneCore::UI.verbose("Cloning simulator")
40
+ cloned_simulator = simulator.clone
41
+ new_first_name = simulator.name.sub(/( ?\(.*| ?$)/, " Clone #{batch_index}")
42
+ new_last_name = "#{self.class.name}<#{self.object_id}>"
43
+ cloned_simulator.rename("#{new_first_name} #{new_last_name}")
44
+
45
+ cloned_simulators.last << cloned_simulator
46
+ end
47
+ end
48
+ cloned_simulators
49
+ end
50
+
51
+ def delete_multi_scan_cloned_simulators
52
+ FastlaneCore::DeviceManager.simulators('iOS').each do |simulator|
53
+ simulator.delete if /#{self.class.name}<\d+>/ =~ simulator.name
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,317 @@
1
+ module TestCenter
2
+ module Helper
3
+ module MultiScanManager
4
+ require 'scan'
5
+ require 'colorize'
6
+ require_relative './device_manager'
7
+
8
+ class Parallelization
9
+ def initialize(batch_count, output_directory, testrun_completed_block)
10
+ @batch_count = batch_count
11
+ @output_directory = output_directory
12
+ @testrun_completed_block = testrun_completed_block
13
+
14
+ @simulators ||= []
15
+
16
+ if ENV['USE_REFACTORED_PARALLELIZED_MULTI_SCAN']
17
+ @simhelper = SimulatorHelper.new(
18
+ parallelize: true,
19
+ batch_count: batch_count
20
+ )
21
+ @simhelper.setup
22
+ end
23
+ if @batch_count < 1
24
+ raise FastlaneCore::FastlaneCrash.new({}), "batch_count (#{@batch_count}) < 1, this should never happen"
25
+ end
26
+ ObjectSpace.define_finalizer(self, self.class.finalize)
27
+ end
28
+
29
+ def self.finalize
30
+ proc { cleanup_simulators }
31
+ end
32
+
33
+ def setup_simulators(devices, batch_deploymentversions)
34
+ if ENV['USE_REFACTORED_PARALLELIZED_MULTI_SCAN']
35
+ @simulators = @simhelper.clone_destination_simulators
36
+ else
37
+ FastlaneCore::DeviceManager.simulators('iOS').each do |simulator|
38
+ simulator.delete if /-batchclone-/ =~ simulator.name
39
+ end
40
+
41
+ (0...@batch_count).each do |batch_index|
42
+ found_simulator_devices = []
43
+ if devices.count > 0
44
+ found_simulator_devices = detect_simulator(devices, batch_deploymentversions[batch_index])
45
+ else
46
+ found_simulator_devices = Scan::DetectValues.detect_simulator(devices, 'iOS', 'IPHONEOS_DEPLOYMENT_TARGET', 'iPhone 5s', nil)
47
+ end
48
+ @simulators[batch_index] ||= []
49
+ found_simulator_devices.each do |found_simulator_device|
50
+ device_for_batch = found_simulator_device.clone
51
+ new_name = "#{found_simulator_device.name.gsub(/[^a-zA-Z\d]/,'')}-batchclone-#{batch_index + 1}"
52
+ device_for_batch.rename(new_name)
53
+ device_for_batch.boot
54
+ @simulators[batch_index] << device_for_batch
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ def detect_simulator(devices, deployment_target_version)
61
+ require 'set'
62
+
63
+ simulators = Scan::DetectValues.filter_simulators(
64
+ FastlaneCore::DeviceManager.simulators('iOS').tap do |array|
65
+ if array.empty?
66
+ FastlaneCore::UI.user_error!(['No', simulator_type_descriptor, 'simulators found on local machine'].reject(&:nil?).join(' '))
67
+ end
68
+ end,
69
+ :greater_than_or_equal,
70
+ deployment_target_version
71
+ ).tap do |sims|
72
+ if sims.empty?
73
+ FastlaneCore::UI.error("No simulators found that are greater than or equal to the version of deployment target (#{deployment_target_version})")
74
+ end
75
+ end
76
+
77
+ # At this point we have all simulators for the given deployment target (or higher)
78
+
79
+ # We create 2 lambdas, which we iterate over later on
80
+ # If the first lambda `matches` found a simulator to use
81
+ # we'll never call the second one
82
+
83
+ matches = lambda do
84
+ set_of_simulators = devices.inject(
85
+ Set.new # of simulators
86
+ ) do |set, device_string|
87
+ pieces = device_string.split(/\s(?=\([\d\.]+\)$)/)
88
+
89
+ selector = ->(sim) { pieces.count > 0 && sim.name == pieces.first }
90
+
91
+ set + (
92
+ if pieces.count == 0
93
+ [] # empty array
94
+ elsif pieces.count == 1
95
+ simulators
96
+ .select(&selector)
97
+ .reverse # more efficient, because `simctl` prints higher versions first
98
+ .sort_by! { |sim| Gem::Version.new(sim.os_version) }
99
+ .pop(1)
100
+ else # pieces.count == 2 -- mathematically, because of the 'end of line' part of our regular expression
101
+ version = pieces[1].tr('()', '')
102
+ potential_emptiness_error = lambda do |sims|
103
+ if sims.empty?
104
+ FastlaneCore::UI.error("No simulators found that are equal to the version " \
105
+ "of specifier (#{version}) and greater than or equal to the version " \
106
+ "of deployment target (#{deployment_target_version})")
107
+ end
108
+ end
109
+ Scan::DetectValues.filter_simulators(simulators, :equal, version).tap(&potential_emptiness_error).select(&selector)
110
+ end
111
+ ).tap do |array|
112
+ FastlaneCore::UI.error("Ignoring '#{device_string}', couldn't find matching simulator") if array.empty?
113
+ end
114
+ end
115
+
116
+ set_of_simulators.to_a
117
+ end
118
+
119
+ default = lambda do
120
+ FastlaneCore::UI.error("Couldn't find any matching simulators for '#{devices}' - falling back to default simulator") if (devices || []).count > 0
121
+
122
+ result = Array(
123
+ simulators
124
+ .select { |sim| sim.name == default_device_name }
125
+ .reverse # more efficient, because `simctl` prints higher versions first
126
+ .sort_by! { |sim| Gem::Version.new(sim.os_version) }
127
+ .last || simulators.first
128
+ )
129
+
130
+ FastlaneCore::UI.message("Found simulator \"#{result.first.name} (#{result.first.os_version})\"") if result.first
131
+
132
+ result
133
+ end
134
+
135
+ [matches, default].lazy.map { |x|
136
+ arr = x.call
137
+ arr unless arr.empty?
138
+ }.reject(&:nil?).first
139
+ end
140
+
141
+ def cleanup_simulators
142
+ @simulators.flatten.each(&:delete)
143
+ @simulators = []
144
+ end
145
+
146
+ def destination_for_batch(batch_index)
147
+ @simulators[batch_index]
148
+ end
149
+
150
+ def devices(batch_index)
151
+ if batch_index > @batch_count
152
+ simulator_count = [@batch_count, @simulators.count].max
153
+ raise "Error: impossible to request devices for batch #{batch_index}, there are only #{simulator_count} set(s) of simulators"
154
+ end
155
+
156
+ if @simulators.count > 0
157
+ @simulators[batch_index - 1].map do |simulator|
158
+ "#{simulator.name} (#{simulator.os_version})"
159
+ end
160
+ else
161
+ @scan_options[:devices] || Array(@scan_options[:device])
162
+ end
163
+ end
164
+
165
+ def ensure_conflict_free_scanlogging(scan_options, batch_index)
166
+ scan_options[:buildlog_path] = scan_options[:buildlog_path] + "-#{batch_index}"
167
+ end
168
+
169
+ def ensure_devices_cloned_for_testrun_are_used(scan_options, batch_index)
170
+ scan_options.delete(:device)
171
+ scan_options[:devices] = devices(batch_index)
172
+ end
173
+
174
+ def setup_scan_options_for_testrun(scan_options, batch_index)
175
+ ensure_conflict_free_scanlogging(scan_options, batch_index)
176
+ ensure_devices_cloned_for_testrun_are_used(scan_options, batch_index)
177
+ end
178
+
179
+ def setup_pipes_for_fork
180
+ @pipe_endpoints = []
181
+ (0...@batch_count).each do
182
+ @pipe_endpoints << IO.pipe
183
+ end
184
+ end
185
+
186
+ def connect_subprocess_endpoint(batch_index)
187
+ mainprocess_reader, = @pipe_endpoints[batch_index]
188
+ mainprocess_reader.close # we are now in the subprocess
189
+ FileUtils.mkdir_p(@output_directory)
190
+ subprocess_output_dir = Dir.mktmpdir
191
+ subprocess_logfilepath = File.join(subprocess_output_dir, "batchscan_#{batch_index}.log")
192
+ $subprocess_logfile = File.open(subprocess_logfilepath, 'w')
193
+ $subprocess_logfile.sync = true
194
+ $old_stdout = $stdout.dup
195
+ $old_stderr = $stderr.dup
196
+ $stdout.reopen($subprocess_logfile)
197
+ $stderr.reopen($subprocess_logfile)
198
+ end
199
+
200
+ def disconnect_subprocess_endpoints
201
+ # This is done from the parent process to close the pipe from its end so
202
+ # that its reading of the pipe doesn't block waiting for more IO on the
203
+ # writer.
204
+ # This has to be done after the fork, because we don't want the subprocess
205
+ # to receive its endpoint already closed.
206
+ @pipe_endpoints.each { |_, subprocess_writer| subprocess_writer.close }
207
+ end
208
+
209
+ def send_subprocess_tryinfo(info)
210
+ puts "in send_subprocess_tryinfo"
211
+ _, subprocess_writer = @pipe_endpoints[batch_index]
212
+ subprocess_output = {
213
+ 'message_type' => 'tryinfo',
214
+ 'batch_index' => batch_index,
215
+ 'tryinfo' => info
216
+ }
217
+ subprocess_writer.puts subprocess_output.to_json
218
+ end
219
+
220
+ def send_subprocess_result(batch_index, result)
221
+ $stdout = $old_stdout.dup
222
+ $stderr = $old_stderr.dup
223
+ _, subprocess_writer = @pipe_endpoints[batch_index]
224
+
225
+ subprocess_output = {
226
+ 'message_type' => 'completed',
227
+ 'batch_index' => batch_index,
228
+ 'subprocess_logfilepath' => $subprocess_logfile.path,
229
+ 'tests_passed' => result
230
+ }
231
+ subprocess_writer.puts subprocess_output.to_json
232
+ subprocess_writer.close
233
+ $subprocess_logfile.close
234
+ end
235
+
236
+ def parse_subprocess_results(subprocess_index, subprocess_output)
237
+ subprocess_result = {
238
+ 'tests_passed' => false
239
+ }
240
+ if subprocess_output.empty?
241
+ FastlaneCore::UI.error("Something went terribly wrong: no output from parallelized batch #{subprocess_index}!")
242
+ else
243
+ subprocess_result = JSON.parse(subprocess_output)
244
+ end
245
+ subprocess_result
246
+ end
247
+
248
+ def stream_subprocess_result_to_console(index, subprocess_logfilepath)
249
+ puts '-' * 80
250
+ if File.exist?(subprocess_logfilepath)
251
+ simulator_prefix = "[Sim-#{index}]"
252
+ unless ENV['FASTLANE_DISABLE_COLORS']
253
+ colors = String.colors - [:default, :black, :white]
254
+ color = colors.sample
255
+ simulator_prefix = simulator_prefix.colorize(color)
256
+ end
257
+ File.foreach(subprocess_logfilepath) do |line|
258
+ print simulator_prefix, line
259
+ end
260
+ end
261
+ end
262
+
263
+ def handle_subprocesses
264
+ # disconnect_subprocess_endpoints # to ensure no blocking on pipe
265
+ FastlaneCore::Helper.show_loading_indicator("Scanning in #{@batch_count} batches")
266
+ loop do
267
+ break if @pipe_endpoints.size.zero?
268
+
269
+ remaining_pipe_endpoints = @pipe_endpoints.map(&:first).reject { |pe| pe.closed? }
270
+ puts "there are #{remaining_pipe_endpoints.size} endpoints remaining".yellow
271
+ read_array, _, error_array = IO.select(remaining_pipe_endpoints)
272
+ read_array.each do |pipe_reader|
273
+ endpoint_index = @pipe_endpoints.find_index { |pe| pe.first == pipe_reader }
274
+ child_message_size = pipe_reader.stat.size
275
+ child_message = pipe_reader.read_nonblock(child_message_size)
276
+ subprocess_result = parse_subprocess_results(0, child_message)
277
+ if subprocess_result['message_type'] == 'completed'
278
+ stream_subprocess_result_to_console(subprocess_result['batch_index'], subprocess_result['subprocess_logfilepath'])
279
+ pipe_reader.close
280
+ @pipe_endpoints.delete_at(endpoint_index)
281
+ elsif subprocess_result['message_type'] == 'tryinfo'
282
+ puts "in simulator_manager, about to call @testrun_completed_block witth #{subprocess_result['tryinfo']}"
283
+ @testrun_completed_block && @testrun_completed_block.call(subprocess_result['tryinfo'])
284
+ end
285
+ end
286
+ error_array.each { |e| puts e }
287
+ break if error_array.size > 0
288
+ break if read_array.size.zero?
289
+ end
290
+ FastlaneCore::Helper.hide_loading_indicator
291
+ end
292
+
293
+ def wait_for_subprocesses
294
+ disconnect_subprocess_endpoints # to ensure no blocking on the pipe
295
+ FastlaneCore::Helper.show_loading_indicator("Scanning in #{@batch_count} batches")
296
+ Process.waitall
297
+ FastlaneCore::Helper.hide_loading_indicator
298
+ end
299
+
300
+ def handle_subprocesses_results
301
+ tests_passed = false
302
+ FastlaneCore::UI.header("Output from parallelized batch run")
303
+ @pipe_endpoints.each_with_index do |endpoints, index|
304
+ mainprocess_reader, = endpoints
305
+ subprocess_result = parse_subprocess_results(index, mainprocess_reader.read)
306
+ mainprocess_reader.close
307
+ stream_subprocess_result_to_console(index, subprocess_result['subprocess_logfilepath'])
308
+ tests_passed = subprocess_result['tests_passed']
309
+ end
310
+ puts '=' * 80
311
+ tests_passed
312
+ true
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -64,9 +64,12 @@ module TestCenter
64
64
  numbered_filename(@output_files.to_s.split(',')[junit_index])
65
65
  end
66
66
 
67
- def junit_reportname
67
+ def junit_reportname(suffix = '')
68
68
  junit_index = @output_types.split(',').find_index('junit')
69
- @output_files.to_s.split(',')[junit_index]
69
+ report_name = @output_files.to_s.split(',')[junit_index]
70
+ return report_name if suffix.empty?
71
+
72
+ "#{File.basename(report_name, '.*')}-#{suffix}#{junit_filextension}"
70
73
  end
71
74
 
72
75
  def junit_filextension
@@ -90,9 +93,12 @@ module TestCenter
90
93
  numbered_filename(@output_files.to_s.split(',')[html_index])
91
94
  end
92
95
 
93
- def html_reportname
96
+ def html_reportname(suffix = '')
94
97
  html_index = @output_types.split(',').find_index('html')
95
- @output_files.to_s.split(',')[html_index]
98
+ report_name = @output_files.to_s.split(',')[html_index]
99
+ return report_name if suffix.empty?
100
+
101
+ "#{File.basename(report_name, '.*')}-#{suffix}#{html_filextension}"
96
102
  end
97
103
 
98
104
  def html_filextension
@@ -116,9 +122,12 @@ module TestCenter
116
122
  numbered_filename(@output_files.to_s.split(',')[json_index])
117
123
  end
118
124
 
119
- def json_reportname
125
+ def json_reportname(suffix = '')
120
126
  json_index = @output_types.split(',').find_index('json')
121
- @output_files.to_s.split(',')[json_index]
127
+ report_name = @output_files.to_s.split(',')[json_index]
128
+ return report_name if suffix.empty?
129
+
130
+ "#{File.basename(report_name, '.*')}-#{suffix}#{json_filextension}"
122
131
  end
123
132
 
124
133
  def json_filextension
@@ -5,6 +5,8 @@ module TestCenter
5
5
  require 'plist'
6
6
 
7
7
  class TestCollector
8
+ attr_reader :xctestrun_path
9
+
8
10
  def initialize(options)
9
11
  unless options[:xctestrun] || options[:derived_data_path]
10
12
  options[:derived_data_path] = default_derived_data_path(options)
@@ -15,6 +17,7 @@ module TestCenter
15
17
  end
16
18
  @only_testing = options[:only_testing]
17
19
  @skip_testing = options[:skip_testing]
20
+ @batch_count = options[:batch_count]
18
21
  end
19
22
 
20
23
  def default_derived_data_path(options)
@@ -34,7 +37,9 @@ module TestCenter
34
37
  if @only_testing
35
38
  @testables ||= only_testing_to_testables_tests.keys
36
39
  else
37
- @testables ||= Plist.parse_xml(@xctestrun_path).keys
40
+ @testables ||= Plist.parse_xml(@xctestrun_path).keys.reject do |key|
41
+ key == '__xctestrun_metadata__'
42
+ end
38
43
  end
39
44
  end
40
45
  @testables
@@ -49,13 +54,30 @@ module TestCenter
49
54
  tests
50
55
  end
51
56
 
57
+ def xctestrun_known_tests
58
+ config = FastlaneCore::Configuration.create(::Fastlane::Actions::TestsFromXctestrunAction.available_options, xctestrun: @xctestrun_path)
59
+ ::Fastlane::Actions::TestsFromXctestrunAction.run(config)
60
+ end
61
+
52
62
  def testables_tests
53
63
  unless @testables_tests
54
64
  if @only_testing
65
+ known_tests = nil
55
66
  @testables_tests = only_testing_to_testables_tests
67
+
68
+ @testables_tests.each do |testable, tests|
69
+ tests.each_with_index do |test, index|
70
+ if test.count('/') < 2
71
+ known_tests ||= xctestrun_known_tests[testable]
72
+ test_components = test.split('/')
73
+ testsuite = test_components.size == 1 ? test_components[0] : test_components[1]
74
+ @testables_tests[testable][index] = known_tests.select { |known_test| known_test.include?(testsuite) }
75
+ end
76
+ end
77
+ @testables_tests[testable].flatten!
78
+ end
56
79
  else
57
- config = FastlaneCore::Configuration.create(::Fastlane::Actions::TestsFromXctestrunAction.available_options, xctestrun: @xctestrun_path)
58
- @testables_tests = ::Fastlane::Actions::TestsFromXctestrunAction.run(config)
80
+ @testables_tests = xctestrun_known_tests
59
81
  if @skip_testing
60
82
  skipped_testable_tests = Hash.new { |h, k| h[k] = [] }
61
83
  @skip_testing.sort.each do |skipped_test_identifier|
@@ -68,8 +90,30 @@ module TestCenter
68
90
  end
69
91
  end
70
92
  end
93
+
71
94
  @testables_tests
72
95
  end
96
+
97
+ def test_batches
98
+ if @batches.nil?
99
+ @batches = []
100
+ testables.each do |testable|
101
+ testable_tests = testables_tests[testable]
102
+ next if testable_tests.empty?
103
+
104
+ if @batch_count > 1
105
+ slice_count = [(testable_tests.length / @batch_count.to_f).ceil, 1].max
106
+ testable_tests.each_slice(slice_count).to_a.each do |tests_batch|
107
+ @batches << tests_batch
108
+ end
109
+ else
110
+ @batches << testable_tests
111
+ end
112
+ end
113
+ end
114
+
115
+ @batches
116
+ end
73
117
  end
74
118
  end
75
119
  end