fastlane 2.200.0 → 2.201.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +100 -93
- data/fastlane/lib/fastlane/actions/trainer.rb +49 -0
- data/fastlane/lib/fastlane/helper/xcodebuild_formatter_helper.rb +9 -0
- data/fastlane/lib/fastlane/tools.rb +2 -1
- data/fastlane/lib/fastlane/version.rb +1 -1
- data/fastlane/swift/Deliverfile.swift +1 -1
- data/fastlane/swift/DeliverfileProtocol.swift +1 -1
- data/fastlane/swift/Fastlane.swift +134 -43
- data/fastlane/swift/Gymfile.swift +1 -1
- data/fastlane/swift/GymfileProtocol.swift +10 -6
- data/fastlane/swift/Matchfile.swift +1 -1
- data/fastlane/swift/MatchfileProtocol.swift +1 -1
- data/fastlane/swift/Precheckfile.swift +1 -1
- data/fastlane/swift/PrecheckfileProtocol.swift +1 -1
- data/fastlane/swift/Scanfile.swift +1 -1
- data/fastlane/swift/ScanfileProtocol.swift +18 -6
- data/fastlane/swift/Screengrabfile.swift +1 -1
- data/fastlane/swift/ScreengrabfileProtocol.swift +1 -1
- data/fastlane/swift/Snapshotfile.swift +1 -1
- data/fastlane/swift/SnapshotfileProtocol.swift +9 -5
- data/fastlane/swift/formatting/Brewfile.lock.json +13 -13
- data/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb +7 -0
- data/gym/lib/gym/generators/build_command_generator.rb +67 -21
- data/gym/lib/gym/options.rb +17 -5
- data/scan/lib/scan/options.rb +30 -5
- data/scan/lib/scan/runner.rb +121 -14
- data/scan/lib/scan/test_command_generator.rb +55 -5
- data/snapshot/lib/snapshot/options.rb +23 -7
- data/snapshot/lib/snapshot/test_command_generator.rb +37 -2
- data/trainer/lib/assets/junit.xml.erb +20 -0
- data/trainer/lib/trainer/commands_generator.rb +51 -0
- data/trainer/lib/trainer/junit_generator.rb +31 -0
- data/trainer/lib/trainer/module.rb +10 -0
- data/trainer/lib/trainer/options.rb +55 -0
- data/trainer/lib/trainer/test_parser.rb +376 -0
- data/trainer/lib/trainer/xcresult.rb +403 -0
- data/trainer/lib/trainer.rb +7 -0
- metadata +29 -18
@@ -0,0 +1,376 @@
|
|
1
|
+
require 'plist'
|
2
|
+
|
3
|
+
require 'fastlane_core/print_table'
|
4
|
+
|
5
|
+
require_relative 'junit_generator'
|
6
|
+
require_relative 'xcresult'
|
7
|
+
require_relative 'module'
|
8
|
+
|
9
|
+
module Trainer
|
10
|
+
class TestParser
|
11
|
+
attr_accessor :data
|
12
|
+
|
13
|
+
attr_accessor :file_content
|
14
|
+
|
15
|
+
attr_accessor :raw_json
|
16
|
+
|
17
|
+
attr_accessor :number_of_tests
|
18
|
+
attr_accessor :number_of_failures
|
19
|
+
attr_accessor :number_of_tests_excluding_retries
|
20
|
+
attr_accessor :number_of_failures_excluding_retries
|
21
|
+
attr_accessor :number_of_retries
|
22
|
+
|
23
|
+
# Returns a hash with the path being the key, and the value
|
24
|
+
# defining if the tests were successful
|
25
|
+
def self.auto_convert(config)
|
26
|
+
unless config[:silent]
|
27
|
+
FastlaneCore::PrintTable.print_values(config: config,
|
28
|
+
title: "Summary for trainer #{Fastlane::VERSION}")
|
29
|
+
end
|
30
|
+
|
31
|
+
containing_dir = config[:path]
|
32
|
+
# Xcode < 10
|
33
|
+
files = Dir["#{containing_dir}/**/Logs/Test/*TestSummaries.plist"]
|
34
|
+
files += Dir["#{containing_dir}/Test/*TestSummaries.plist"]
|
35
|
+
files += Dir["#{containing_dir}/*TestSummaries.plist"]
|
36
|
+
# Xcode 10
|
37
|
+
files += Dir["#{containing_dir}/**/Logs/Test/*.xcresult/TestSummaries.plist"]
|
38
|
+
files += Dir["#{containing_dir}/Test/*.xcresult/TestSummaries.plist"]
|
39
|
+
files += Dir["#{containing_dir}/*.xcresult/TestSummaries.plist"]
|
40
|
+
files += Dir[containing_dir] if containing_dir.end_with?(".plist") # if it's the exact path to a plist file
|
41
|
+
# Xcode 11
|
42
|
+
files += Dir["#{containing_dir}/**/Logs/Test/*.xcresult"]
|
43
|
+
files += Dir["#{containing_dir}/Test/*.xcresult"]
|
44
|
+
files += Dir["#{containing_dir}/*.xcresult"]
|
45
|
+
files << containing_dir if File.extname(containing_dir) == ".xcresult"
|
46
|
+
|
47
|
+
if files.empty?
|
48
|
+
UI.user_error!("No test result files found in directory '#{containing_dir}', make sure the file name ends with 'TestSummaries.plist' or '.xcresult'")
|
49
|
+
end
|
50
|
+
|
51
|
+
return_hash = {}
|
52
|
+
files.each do |path|
|
53
|
+
extension = config[:extension]
|
54
|
+
output_filename = config[:output_filename]
|
55
|
+
|
56
|
+
should_write_file = !extension.nil? || !output_filename.nil?
|
57
|
+
|
58
|
+
if should_write_file
|
59
|
+
if config[:output_directory]
|
60
|
+
FileUtils.mkdir_p(config[:output_directory])
|
61
|
+
# Remove .xcresult or .plist extension
|
62
|
+
# Use custom file name ONLY if one file otherwise issues
|
63
|
+
if files.size == 1 && output_filename
|
64
|
+
filename = output_filename
|
65
|
+
elsif path.end_with?(".xcresult")
|
66
|
+
filename ||= File.basename(path).gsub(".xcresult", extension)
|
67
|
+
else
|
68
|
+
filename ||= File.basename(path).gsub(".plist", extension)
|
69
|
+
end
|
70
|
+
to_path = File.join(config[:output_directory], filename)
|
71
|
+
else
|
72
|
+
# Remove .xcresult or .plist extension
|
73
|
+
if path.end_with?(".xcresult")
|
74
|
+
to_path = path.gsub(".xcresult", extension)
|
75
|
+
else
|
76
|
+
to_path = path.gsub(".plist", extension)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
tp = Trainer::TestParser.new(path, config)
|
82
|
+
File.write(to_path, tp.to_junit) if should_write_file
|
83
|
+
UI.success("Successfully generated '#{to_path}'") if should_write_file && !config[:silent]
|
84
|
+
|
85
|
+
return_hash[path] = {
|
86
|
+
to_path: to_path,
|
87
|
+
successful: tp.tests_successful?,
|
88
|
+
number_of_tests: tp.number_of_tests,
|
89
|
+
number_of_failures: tp.number_of_failures,
|
90
|
+
number_of_tests_excluding_retries: tp.number_of_tests_excluding_retries,
|
91
|
+
number_of_failures_excluding_retries: tp.number_of_failures_excluding_retries,
|
92
|
+
number_of_retries: tp.number_of_retries
|
93
|
+
}
|
94
|
+
end
|
95
|
+
return_hash
|
96
|
+
end
|
97
|
+
|
98
|
+
def initialize(path, config = {})
|
99
|
+
path = File.expand_path(path)
|
100
|
+
UI.user_error!("File not found at path '#{path}'") unless File.exist?(path)
|
101
|
+
|
102
|
+
if File.directory?(path) && path.end_with?(".xcresult")
|
103
|
+
parse_xcresult(path, output_remove_retry_attempts: config[:output_remove_retry_attempts])
|
104
|
+
else
|
105
|
+
self.file_content = File.read(path)
|
106
|
+
self.raw_json = Plist.parse_xml(self.file_content)
|
107
|
+
|
108
|
+
return if self.raw_json["FormatVersion"].to_s.length.zero? # maybe that's a useless plist file
|
109
|
+
|
110
|
+
ensure_file_valid!
|
111
|
+
parse_content(config[:xcpretty_naming])
|
112
|
+
end
|
113
|
+
|
114
|
+
self.number_of_tests = 0
|
115
|
+
self.number_of_failures = 0
|
116
|
+
self.number_of_tests_excluding_retries = 0
|
117
|
+
self.number_of_failures_excluding_retries = 0
|
118
|
+
self.number_of_retries = 0
|
119
|
+
self.data.each do |thing|
|
120
|
+
self.number_of_tests += thing[:number_of_tests].to_i
|
121
|
+
self.number_of_failures += thing[:number_of_failures].to_i
|
122
|
+
self.number_of_tests_excluding_retries += thing[:number_of_tests_excluding_retries].to_i
|
123
|
+
self.number_of_failures_excluding_retries += thing[:number_of_failures_excluding_retries].to_i
|
124
|
+
self.number_of_retries += thing[:number_of_retries].to_i
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Returns the JUnit report as String
|
129
|
+
def to_junit
|
130
|
+
JunitGenerator.new(self.data).generate
|
131
|
+
end
|
132
|
+
|
133
|
+
# @return [Bool] were all tests successful? Is false if at least one test failed
|
134
|
+
def tests_successful?
|
135
|
+
self.data.collect { |a| a[:number_of_failures] }.all?(&:zero?)
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def ensure_file_valid!
|
141
|
+
format_version = self.raw_json["FormatVersion"]
|
142
|
+
supported_versions = ["1.1", "1.2"]
|
143
|
+
UI.user_error!("Format version '#{format_version}' is not supported, must be #{supported_versions.join(', ')}") unless supported_versions.include?(format_version)
|
144
|
+
end
|
145
|
+
|
146
|
+
# Converts the raw plist test structure into something that's easier to enumerate
|
147
|
+
def unfold_tests(data)
|
148
|
+
# `data` looks like this
|
149
|
+
# => [{"Subtests"=>
|
150
|
+
# [{"Subtests"=>
|
151
|
+
# [{"Subtests"=>
|
152
|
+
# [{"Duration"=>0.4,
|
153
|
+
# "TestIdentifier"=>"Unit/testExample()",
|
154
|
+
# "TestName"=>"testExample()",
|
155
|
+
# "TestObjectClass"=>"IDESchemeActionTestSummary",
|
156
|
+
# "TestStatus"=>"Success",
|
157
|
+
# "TestSummaryGUID"=>"4A24BFED-03E6-4FBE-BC5E-2D80023C06B4"},
|
158
|
+
# {"FailureSummaries"=>
|
159
|
+
# [{"FileName"=>"/Users/krausefx/Developer/themoji/Unit/Unit.swift",
|
160
|
+
# "LineNumber"=>34,
|
161
|
+
# "Message"=>"XCTAssertTrue failed - ",
|
162
|
+
# "PerformanceFailure"=>false}],
|
163
|
+
# "TestIdentifier"=>"Unit/testExample2()",
|
164
|
+
|
165
|
+
tests = []
|
166
|
+
data.each do |current_hash|
|
167
|
+
if current_hash["Subtests"]
|
168
|
+
tests += unfold_tests(current_hash["Subtests"])
|
169
|
+
end
|
170
|
+
if current_hash["TestStatus"]
|
171
|
+
tests << current_hash
|
172
|
+
end
|
173
|
+
end
|
174
|
+
return tests
|
175
|
+
end
|
176
|
+
|
177
|
+
# Returns the test group and test name from the passed summary and test
|
178
|
+
# Pass xcpretty_naming = true to get the test naming aligned with xcpretty
|
179
|
+
def test_group_and_name(testable_summary, test, xcpretty_naming)
|
180
|
+
if xcpretty_naming
|
181
|
+
group = testable_summary["TargetName"] + "." + test["TestIdentifier"].split("/")[0..-2].join(".")
|
182
|
+
name = test["TestName"][0..-3]
|
183
|
+
else
|
184
|
+
group = test["TestIdentifier"].split("/")[0..-2].join(".")
|
185
|
+
name = test["TestName"]
|
186
|
+
end
|
187
|
+
return group, name
|
188
|
+
end
|
189
|
+
|
190
|
+
def execute_cmd(cmd)
|
191
|
+
output = `#{cmd}`
|
192
|
+
raise "Failed to execute - #{cmd}" unless $?.success?
|
193
|
+
return output
|
194
|
+
end
|
195
|
+
|
196
|
+
def parse_xcresult(path, output_remove_retry_attempts: false)
|
197
|
+
require 'shellwords'
|
198
|
+
path = Shellwords.escape(path)
|
199
|
+
|
200
|
+
# Executes xcresulttool to get JSON format of the result bundle object
|
201
|
+
result_bundle_object_raw = execute_cmd("xcrun xcresulttool get --format json --path #{path}")
|
202
|
+
result_bundle_object = JSON.parse(result_bundle_object_raw)
|
203
|
+
|
204
|
+
# Parses JSON into ActionsInvocationRecord to find a list of all ids for ActionTestPlanRunSummaries
|
205
|
+
actions_invocation_record = Trainer::XCResult::ActionsInvocationRecord.new(result_bundle_object)
|
206
|
+
test_refs = actions_invocation_record.actions.map do |action|
|
207
|
+
action.action_result.tests_ref
|
208
|
+
end.compact
|
209
|
+
ids = test_refs.map(&:id)
|
210
|
+
|
211
|
+
# Maps ids into ActionTestPlanRunSummaries by executing xcresulttool to get JSON
|
212
|
+
# containing specific information for each test summary,
|
213
|
+
summaries = ids.map do |id|
|
214
|
+
raw = execute_cmd("xcrun xcresulttool get --format json --path #{path} --id #{id}")
|
215
|
+
json = JSON.parse(raw)
|
216
|
+
Trainer::XCResult::ActionTestPlanRunSummaries.new(json)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Converts the ActionTestPlanRunSummaries to data for junit generator
|
220
|
+
failures = actions_invocation_record.issues.test_failure_summaries || []
|
221
|
+
summaries_to_data(summaries, failures, output_remove_retry_attempts: output_remove_retry_attempts)
|
222
|
+
end
|
223
|
+
|
224
|
+
def summaries_to_data(summaries, failures, output_remove_retry_attempts: false)
|
225
|
+
# Gets flat list of all ActionTestableSummary
|
226
|
+
all_summaries = summaries.map(&:summaries).flatten
|
227
|
+
testable_summaries = all_summaries.map(&:testable_summaries).flatten
|
228
|
+
|
229
|
+
# Maps ActionTestableSummary to rows for junit generator
|
230
|
+
rows = testable_summaries.map do |testable_summary|
|
231
|
+
all_tests = testable_summary.all_tests.flatten
|
232
|
+
|
233
|
+
# Used by store number of passes and failures by identifier
|
234
|
+
# This is used when Xcode 13 (and up) retries tests
|
235
|
+
# The identifier is duplicated until test succeeds or max count is reachd
|
236
|
+
tests_by_identifier = {}
|
237
|
+
|
238
|
+
test_rows = all_tests.map do |test|
|
239
|
+
identifier = "#{test.parent.name}.#{test.name}"
|
240
|
+
test_row = {
|
241
|
+
identifier: identifier,
|
242
|
+
name: test.name,
|
243
|
+
duration: test.duration,
|
244
|
+
status: test.test_status,
|
245
|
+
test_group: test.parent.name,
|
246
|
+
|
247
|
+
# These don't map to anything but keeping empty strings
|
248
|
+
guid: ""
|
249
|
+
}
|
250
|
+
|
251
|
+
info = tests_by_identifier[identifier] || {}
|
252
|
+
info[:failure_count] ||= 0
|
253
|
+
info[:success_count] ||= 0
|
254
|
+
|
255
|
+
retry_count = info[:retry_count]
|
256
|
+
if retry_count.nil?
|
257
|
+
retry_count = 0
|
258
|
+
else
|
259
|
+
retry_count += 1
|
260
|
+
end
|
261
|
+
info[:retry_count] = retry_count
|
262
|
+
|
263
|
+
# Set failure message if failure found
|
264
|
+
failure = test.find_failure(failures)
|
265
|
+
if failure
|
266
|
+
test_row[:failures] = [{
|
267
|
+
file_name: "",
|
268
|
+
line_number: 0,
|
269
|
+
message: "",
|
270
|
+
performance_failure: {},
|
271
|
+
failure_message: failure.failure_message
|
272
|
+
}]
|
273
|
+
|
274
|
+
info[:failure_count] += 1
|
275
|
+
else
|
276
|
+
info[:success_count] = 1
|
277
|
+
end
|
278
|
+
|
279
|
+
tests_by_identifier[identifier] = info
|
280
|
+
|
281
|
+
test_row
|
282
|
+
end
|
283
|
+
|
284
|
+
# Remove retry attempts from the count and test rows
|
285
|
+
if output_remove_retry_attempts
|
286
|
+
test_rows = test_rows.reject do |test_row|
|
287
|
+
remove = false
|
288
|
+
|
289
|
+
identifier = test_row[:identifier]
|
290
|
+
info = tests_by_identifier[identifier]
|
291
|
+
|
292
|
+
# Remove if this row is a retry and is a failure
|
293
|
+
if info[:retry_count] > 0
|
294
|
+
remove = !(test_row[:failures] || []).empty?
|
295
|
+
end
|
296
|
+
|
297
|
+
# Remove all failure and retry count if test did eventually pass
|
298
|
+
if remove
|
299
|
+
info[:failure_count] -= 1
|
300
|
+
info[:retry_count] -= 1
|
301
|
+
tests_by_identifier[identifier] = info
|
302
|
+
end
|
303
|
+
|
304
|
+
remove
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
row = {
|
309
|
+
project_path: testable_summary.project_relative_path,
|
310
|
+
target_name: testable_summary.target_name,
|
311
|
+
test_name: testable_summary.name,
|
312
|
+
duration: all_tests.map(&:duration).inject(:+),
|
313
|
+
tests: test_rows
|
314
|
+
}
|
315
|
+
|
316
|
+
row[:number_of_tests] = row[:tests].count
|
317
|
+
row[:number_of_failures] = row[:tests].find_all { |a| (a[:failures] || []).count > 0 }.count
|
318
|
+
|
319
|
+
# Used for seeing if any tests continued to fail after all of the Xcode 13 (and up) retries have finished
|
320
|
+
unique_tests = tests_by_identifier.values || []
|
321
|
+
row[:number_of_tests_excluding_retries] = unique_tests.count
|
322
|
+
row[:number_of_failures_excluding_retries] = unique_tests.find_all { |a| a[:success_count] == 0 }.count
|
323
|
+
row[:number_of_retries] = unique_tests.map { |a| a[:retry_count] }.inject(:+)
|
324
|
+
|
325
|
+
row
|
326
|
+
end
|
327
|
+
|
328
|
+
self.data = rows
|
329
|
+
end
|
330
|
+
|
331
|
+
# Convert the Hashes and Arrays in something more useful
|
332
|
+
def parse_content(xcpretty_naming)
|
333
|
+
self.data = self.raw_json["TestableSummaries"].collect do |testable_summary|
|
334
|
+
summary_row = {
|
335
|
+
project_path: testable_summary["ProjectPath"],
|
336
|
+
target_name: testable_summary["TargetName"],
|
337
|
+
test_name: testable_summary["TestName"],
|
338
|
+
duration: testable_summary["Tests"].map { |current_test| current_test["Duration"] }.inject(:+),
|
339
|
+
tests: unfold_tests(testable_summary["Tests"]).collect do |current_test|
|
340
|
+
test_group, test_name = test_group_and_name(testable_summary, current_test, xcpretty_naming)
|
341
|
+
current_row = {
|
342
|
+
identifier: current_test["TestIdentifier"],
|
343
|
+
test_group: test_group,
|
344
|
+
name: test_name,
|
345
|
+
object_class: current_test["TestObjectClass"],
|
346
|
+
status: current_test["TestStatus"],
|
347
|
+
guid: current_test["TestSummaryGUID"],
|
348
|
+
duration: current_test["Duration"]
|
349
|
+
}
|
350
|
+
if current_test["FailureSummaries"]
|
351
|
+
current_row[:failures] = current_test["FailureSummaries"].collect do |current_failure|
|
352
|
+
{
|
353
|
+
file_name: current_failure['FileName'],
|
354
|
+
line_number: current_failure['LineNumber'],
|
355
|
+
message: current_failure['Message'],
|
356
|
+
performance_failure: current_failure['PerformanceFailure'],
|
357
|
+
failure_message: "#{current_failure['Message']} (#{current_failure['FileName']}:#{current_failure['LineNumber']})"
|
358
|
+
}
|
359
|
+
end
|
360
|
+
end
|
361
|
+
current_row
|
362
|
+
end
|
363
|
+
}
|
364
|
+
summary_row[:number_of_tests] = summary_row[:tests].count
|
365
|
+
summary_row[:number_of_failures] = summary_row[:tests].find_all { |a| (a[:failures] || []).count > 0 }.count
|
366
|
+
|
367
|
+
# Makes sure that plist support matches data output of xcresult
|
368
|
+
summary_row[:number_of_tests_excluding_retries] = summary_row[:number_of_tests]
|
369
|
+
summary_row[:number_of_failures_excluding_retries] = summary_row[:number_of_failures]
|
370
|
+
summary_row[:number_of_retries] = 0
|
371
|
+
|
372
|
+
summary_row
|
373
|
+
end
|
374
|
+
end
|
375
|
+
end
|
376
|
+
end
|