fastlane 2.200.0 → 2.201.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,51 @@
1
+ require 'commander'
2
+
3
+ require 'fastlane_core/configuration/configuration'
4
+ require 'fastlane_core/ui/help_formatter'
5
+
6
+ require_relative 'options'
7
+ require_relative 'test_parser'
8
+
9
+ require_relative 'module'
10
+
11
+ HighLine.track_eof = false
12
+
13
+ module Trainer
14
+ class CommandsGenerator
15
+ include Commander::Methods
16
+
17
+ def self.start
18
+ self.new.run
19
+ end
20
+
21
+ def run
22
+ program :version, Fastlane::VERSION
23
+ program :description, Trainer::DESCRIPTION
24
+ program :help, 'Author', 'Felix Krause <trainer@krausefx.com>'
25
+ program :help, 'Website', 'https://fastlane.tools'
26
+ program :help, 'GitHub', 'https://github.com/KrauseFx/trainer'
27
+ program :help_formatter, :compact
28
+
29
+ global_option('--verbose', 'Shows a more verbose output') { $verbose = true }
30
+
31
+ always_trace!
32
+
33
+ FastlaneCore::CommanderGenerator.new.generate(Trainer::Options.available_options)
34
+
35
+ command :run do |c|
36
+ c.syntax = 'trainer'
37
+ c.description = Trainer::DESCRIPTION
38
+
39
+ c.action do |args, options|
40
+ options = FastlaneCore::Configuration.create(Trainer::Options.available_options, options.__hash__)
41
+ FastlaneCore::PrintTable.print_values(config: options, title: "Summary for trainer #{Fastlane::VERSION}") if $verbose
42
+ Trainer::TestParser.auto_convert(options)
43
+ end
44
+ end
45
+
46
+ default_command(:run)
47
+
48
+ run!
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ require_relative 'module'
2
+
3
+ module Trainer
4
+ class JunitGenerator
5
+ attr_accessor :results
6
+
7
+ def initialize(results)
8
+ self.results = results
9
+ end
10
+
11
+ def generate
12
+ # JUnit file documentation: http://llg.cubic.org/docs/junit/
13
+ # And http://nelsonwells.net/2012/09/how-jenkins-ci-parses-and-displays-junit-output/
14
+ # And http://windyroad.com.au/dl/Open%20Source/JUnit.xsd
15
+
16
+ lib_path = Trainer::ROOT
17
+ xml_path = File.join(lib_path, "lib/assets/junit.xml.erb")
18
+ xml = ERB.new(File.read(xml_path), nil, '<>').result(binding) # http://www.rrn.dk/rubys-erb-templating-system
19
+
20
+ xml = xml.gsub('system_', 'system-').delete("\e") # Jenkins can not parse 'ESC' symbol
21
+
22
+ # We have to manuall clear empty lines
23
+ # They may contain white spaces
24
+ clean_xml = []
25
+ xml.each_line do |row|
26
+ clean_xml << row.delete("\n") if row.strip.to_s.length > 0
27
+ end
28
+ return clean_xml.join("\n")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ require 'fastlane_core/helper'
2
+ require 'fastlane/boolean'
3
+
4
+ module Trainer
5
+ Helper = FastlaneCore::Helper # you gotta love Ruby: Helper.* should use the Helper class contained in FastlaneCore
6
+ UI = FastlaneCore::UI
7
+ Boolean = Fastlane::Boolean
8
+ ROOT = Pathname.new(File.expand_path('../../..', __FILE__))
9
+ DESCRIPTION = "Convert xcodebuild plist and xcresult files to JUnit reports"
10
+ end
@@ -0,0 +1,55 @@
1
+ require 'fastlane_core/configuration/config_item'
2
+
3
+ require_relative 'module'
4
+
5
+ module Trainer
6
+ class Options
7
+ def self.available_options
8
+ @options ||= [
9
+ FastlaneCore::ConfigItem.new(key: :path,
10
+ short_option: "-p",
11
+ env_name: "TRAINER_PATH",
12
+ default_value: ".",
13
+ description: "Path to the directory that should be converted",
14
+ verify_block: proc do |value|
15
+ v = File.expand_path(value.to_s)
16
+ if v.end_with?(".plist")
17
+ UI.user_error!("Can't find file at path #{v}") unless File.exist?(v)
18
+ else
19
+ UI.user_error!("Path '#{v}' is not a directory or can't be found") unless File.directory?(v)
20
+ end
21
+ end),
22
+ FastlaneCore::ConfigItem.new(key: :extension,
23
+ short_option: "-e",
24
+ env_name: "TRAINER_EXTENSION",
25
+ default_value: ".xml",
26
+ description: "The extension for the newly created file. Usually .xml or .junit",
27
+ verify_block: proc do |value|
28
+ UI.user_error!("extension must contain a `.`") unless value.include?(".")
29
+ end),
30
+ FastlaneCore::ConfigItem.new(key: :output_directory,
31
+ short_option: "-o",
32
+ env_name: "TRAINER_OUTPUT_DIRECTORY",
33
+ default_value: nil,
34
+ optional: true,
35
+ description: "Directoy in which the xml files should be written to. Same directory as source by default"),
36
+ FastlaneCore::ConfigItem.new(key: :fail_build,
37
+ env_name: "TRAINER_FAIL_BUILD",
38
+ description: "Should this step stop the build if the tests fail? Set this to false if you're handling this with a test reporter",
39
+ is_string: false,
40
+ default_value: true),
41
+ FastlaneCore::ConfigItem.new(key: :xcpretty_naming,
42
+ short_option: "-x",
43
+ env_name: "TRAINER_XCPRETTY_NAMING",
44
+ description: "Produces class name and test name identical to xcpretty naming in junit file",
45
+ is_string: false,
46
+ default_value: false),
47
+ FastlaneCore::ConfigItem.new(key: :silent,
48
+ env_name: "TRAINER_SILENT",
49
+ description: "Silences all output",
50
+ is_string: false,
51
+ default_value: false)
52
+ ]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,335 @@
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
+ if config[:output_directory]
54
+ FileUtils.mkdir_p(config[:output_directory])
55
+ # Remove .xcresult or .plist extension
56
+ if path.end_with?(".xcresult")
57
+ filename = File.basename(path).gsub(".xcresult", config[:extension])
58
+ else
59
+ filename = File.basename(path).gsub(".plist", config[:extension])
60
+ end
61
+ to_path = File.join(config[:output_directory], filename)
62
+ else
63
+ # Remove .xcresult or .plist extension
64
+ if path.end_with?(".xcresult")
65
+ to_path = path.gsub(".xcresult", config[:extension])
66
+ else
67
+ to_path = path.gsub(".plist", config[:extension])
68
+ end
69
+ end
70
+
71
+ tp = Trainer::TestParser.new(path, config)
72
+ File.write(to_path, tp.to_junit)
73
+ UI.success("Successfully generated '#{to_path}'") unless config[:silent]
74
+
75
+ return_hash[to_path] = {
76
+ successful: tp.tests_successful?,
77
+ number_of_tests: tp.number_of_tests,
78
+ number_of_failures: tp.number_of_failures,
79
+ number_of_tests_excluding_retries: tp.number_of_tests_excluding_retries,
80
+ number_of_failures_excluding_retries: tp.number_of_failures_excluding_retries,
81
+ number_of_retries: tp.number_of_retries
82
+ }
83
+ end
84
+ return_hash
85
+ end
86
+
87
+ def initialize(path, config = {})
88
+ path = File.expand_path(path)
89
+ UI.user_error!("File not found at path '#{path}'") unless File.exist?(path)
90
+
91
+ if File.directory?(path) && path.end_with?(".xcresult")
92
+ parse_xcresult(path)
93
+ else
94
+ self.file_content = File.read(path)
95
+ self.raw_json = Plist.parse_xml(self.file_content)
96
+
97
+ return if self.raw_json["FormatVersion"].to_s.length.zero? # maybe that's a useless plist file
98
+
99
+ ensure_file_valid!
100
+ parse_content(config[:xcpretty_naming])
101
+ end
102
+
103
+ self.number_of_tests = 0
104
+ self.number_of_failures = 0
105
+ self.number_of_tests_excluding_retries = 0
106
+ self.number_of_failures_excluding_retries = 0
107
+ self.number_of_retries = 0
108
+ self.data.each do |thing|
109
+ self.number_of_tests += thing[:number_of_tests].to_i
110
+ self.number_of_failures += thing[:number_of_failures].to_i
111
+ self.number_of_tests_excluding_retries += thing[:number_of_tests_excluding_retries].to_i
112
+ self.number_of_failures_excluding_retries += thing[:number_of_failures_excluding_retries].to_i
113
+ self.number_of_retries += thing[:number_of_retries].to_i
114
+ end
115
+ end
116
+
117
+ # Returns the JUnit report as String
118
+ def to_junit
119
+ JunitGenerator.new(self.data).generate
120
+ end
121
+
122
+ # @return [Bool] were all tests successful? Is false if at least one test failed
123
+ def tests_successful?
124
+ self.data.collect { |a| a[:number_of_failures] }.all?(&:zero?)
125
+ end
126
+
127
+ private
128
+
129
+ def ensure_file_valid!
130
+ format_version = self.raw_json["FormatVersion"]
131
+ supported_versions = ["1.1", "1.2"]
132
+ UI.user_error!("Format version '#{format_version}' is not supported, must be #{supported_versions.join(', ')}") unless supported_versions.include?(format_version)
133
+ end
134
+
135
+ # Converts the raw plist test structure into something that's easier to enumerate
136
+ def unfold_tests(data)
137
+ # `data` looks like this
138
+ # => [{"Subtests"=>
139
+ # [{"Subtests"=>
140
+ # [{"Subtests"=>
141
+ # [{"Duration"=>0.4,
142
+ # "TestIdentifier"=>"Unit/testExample()",
143
+ # "TestName"=>"testExample()",
144
+ # "TestObjectClass"=>"IDESchemeActionTestSummary",
145
+ # "TestStatus"=>"Success",
146
+ # "TestSummaryGUID"=>"4A24BFED-03E6-4FBE-BC5E-2D80023C06B4"},
147
+ # {"FailureSummaries"=>
148
+ # [{"FileName"=>"/Users/krausefx/Developer/themoji/Unit/Unit.swift",
149
+ # "LineNumber"=>34,
150
+ # "Message"=>"XCTAssertTrue failed - ",
151
+ # "PerformanceFailure"=>false}],
152
+ # "TestIdentifier"=>"Unit/testExample2()",
153
+
154
+ tests = []
155
+ data.each do |current_hash|
156
+ if current_hash["Subtests"]
157
+ tests += unfold_tests(current_hash["Subtests"])
158
+ end
159
+ if current_hash["TestStatus"]
160
+ tests << current_hash
161
+ end
162
+ end
163
+ return tests
164
+ end
165
+
166
+ # Returns the test group and test name from the passed summary and test
167
+ # Pass xcpretty_naming = true to get the test naming aligned with xcpretty
168
+ def test_group_and_name(testable_summary, test, xcpretty_naming)
169
+ if xcpretty_naming
170
+ group = testable_summary["TargetName"] + "." + test["TestIdentifier"].split("/")[0..-2].join(".")
171
+ name = test["TestName"][0..-3]
172
+ else
173
+ group = test["TestIdentifier"].split("/")[0..-2].join(".")
174
+ name = test["TestName"]
175
+ end
176
+ return group, name
177
+ end
178
+
179
+ def execute_cmd(cmd)
180
+ output = `#{cmd}`
181
+ raise "Failed to execute - #{cmd}" unless $?.success?
182
+ return output
183
+ end
184
+
185
+ def parse_xcresult(path)
186
+ require 'shellwords'
187
+ path = Shellwords.escape(path)
188
+
189
+ # Executes xcresulttool to get JSON format of the result bundle object
190
+ result_bundle_object_raw = execute_cmd("xcrun xcresulttool get --format json --path #{path}")
191
+ result_bundle_object = JSON.parse(result_bundle_object_raw)
192
+
193
+ # Parses JSON into ActionsInvocationRecord to find a list of all ids for ActionTestPlanRunSummaries
194
+ actions_invocation_record = Trainer::XCResult::ActionsInvocationRecord.new(result_bundle_object)
195
+ test_refs = actions_invocation_record.actions.map do |action|
196
+ action.action_result.tests_ref
197
+ end.compact
198
+ ids = test_refs.map(&:id)
199
+
200
+ # Maps ids into ActionTestPlanRunSummaries by executing xcresulttool to get JSON
201
+ # containing specific information for each test summary,
202
+ summaries = ids.map do |id|
203
+ raw = execute_cmd("xcrun xcresulttool get --format json --path #{path} --id #{id}")
204
+ json = JSON.parse(raw)
205
+ Trainer::XCResult::ActionTestPlanRunSummaries.new(json)
206
+ end
207
+
208
+ # Converts the ActionTestPlanRunSummaries to data for junit generator
209
+ failures = actions_invocation_record.issues.test_failure_summaries || []
210
+ summaries_to_data(summaries, failures)
211
+ end
212
+
213
+ def summaries_to_data(summaries, failures)
214
+ # Gets flat list of all ActionTestableSummary
215
+ all_summaries = summaries.map(&:summaries).flatten
216
+ testable_summaries = all_summaries.map(&:testable_summaries).flatten
217
+
218
+ # Maps ActionTestableSummary to rows for junit generator
219
+ rows = testable_summaries.map do |testable_summary|
220
+ all_tests = testable_summary.all_tests.flatten
221
+
222
+ # Used by store number of passes and failures by identifier
223
+ # This is used when Xcode 13 (and up) retries tests
224
+ # The identifier is duplicated until test succeeds or max count is reachd
225
+ tests_by_identifier = {}
226
+
227
+ test_rows = all_tests.map do |test|
228
+ identifier = "#{test.parent.name}.#{test.name}"
229
+ test_row = {
230
+ identifier: identifier,
231
+ name: test.name,
232
+ duration: test.duration,
233
+ status: test.test_status,
234
+ test_group: test.parent.name,
235
+
236
+ # These don't map to anything but keeping empty strings
237
+ guid: ""
238
+ }
239
+
240
+ info = tests_by_identifier[identifier] || {}
241
+ info[:failure_count] ||= 0
242
+ info[:success_count] ||= 0
243
+
244
+ retry_count = info[:retry_count]
245
+ if retry_count.nil?
246
+ retry_count = 0
247
+ else
248
+ retry_count += 1
249
+ end
250
+ info[:retry_count] = retry_count
251
+
252
+ # Set failure message if failure found
253
+ failure = test.find_failure(failures)
254
+ if failure
255
+ test_row[:failures] = [{
256
+ file_name: "",
257
+ line_number: 0,
258
+ message: "",
259
+ performance_failure: {},
260
+ failure_message: failure.failure_message
261
+ }]
262
+
263
+ info[:failure_count] += 1
264
+ else
265
+ info[:success_count] = 1
266
+ end
267
+
268
+ tests_by_identifier[identifier] = info
269
+
270
+ test_row
271
+ end
272
+
273
+ row = {
274
+ project_path: testable_summary.project_relative_path,
275
+ target_name: testable_summary.target_name,
276
+ test_name: testable_summary.name,
277
+ duration: all_tests.map(&:duration).inject(:+),
278
+ tests: test_rows
279
+ }
280
+
281
+ row[:number_of_tests] = row[:tests].count
282
+ row[:number_of_failures] = row[:tests].find_all { |a| (a[:failures] || []).count > 0 }.count
283
+
284
+ # Used for seeing if any tests continued to fail after all of the Xcode 13 (and up) retries have finished
285
+ unique_tests = tests_by_identifier.values || []
286
+ row[:number_of_tests_excluding_retries] = unique_tests.count
287
+ row[:number_of_failures_excluding_retries] = unique_tests.find_all { |a| a[:success_count] == 0 }.count
288
+ row[:number_of_retries] = unique_tests.map { |a| a[:retry_count] }.inject(:+)
289
+
290
+ row
291
+ end
292
+
293
+ self.data = rows
294
+ end
295
+
296
+ # Convert the Hashes and Arrays in something more useful
297
+ def parse_content(xcpretty_naming)
298
+ self.data = self.raw_json["TestableSummaries"].collect do |testable_summary|
299
+ summary_row = {
300
+ project_path: testable_summary["ProjectPath"],
301
+ target_name: testable_summary["TargetName"],
302
+ test_name: testable_summary["TestName"],
303
+ duration: testable_summary["Tests"].map { |current_test| current_test["Duration"] }.inject(:+),
304
+ tests: unfold_tests(testable_summary["Tests"]).collect do |current_test|
305
+ test_group, test_name = test_group_and_name(testable_summary, current_test, xcpretty_naming)
306
+ current_row = {
307
+ identifier: current_test["TestIdentifier"],
308
+ test_group: test_group,
309
+ name: test_name,
310
+ object_class: current_test["TestObjectClass"],
311
+ status: current_test["TestStatus"],
312
+ guid: current_test["TestSummaryGUID"],
313
+ duration: current_test["Duration"]
314
+ }
315
+ if current_test["FailureSummaries"]
316
+ current_row[:failures] = current_test["FailureSummaries"].collect do |current_failure|
317
+ {
318
+ file_name: current_failure['FileName'],
319
+ line_number: current_failure['LineNumber'],
320
+ message: current_failure['Message'],
321
+ performance_failure: current_failure['PerformanceFailure'],
322
+ failure_message: "#{current_failure['Message']} (#{current_failure['FileName']}:#{current_failure['LineNumber']})"
323
+ }
324
+ end
325
+ end
326
+ current_row
327
+ end
328
+ }
329
+ summary_row[:number_of_tests] = summary_row[:tests].count
330
+ summary_row[:number_of_failures] = summary_row[:tests].find_all { |a| (a[:failures] || []).count > 0 }.count
331
+ summary_row
332
+ end
333
+ end
334
+ end
335
+ end