fastlane 2.200.0 → 2.201.0.rc1

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.
@@ -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