fastlane 2.226.0 → 2.227.0
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.
- checksums.yaml +4 -4
- data/README.md +96 -96
- data/cert/lib/cert/options.rb +7 -2
- data/cert/lib/cert/runner.rb +23 -11
- data/deliver/lib/deliver/options.rb +1 -1
- data/fastlane/lib/fastlane/actions/app_store_build_number.rb +1 -1
- data/fastlane/lib/fastlane/actions/docs/sync_code_signing.md +1 -1
- data/fastlane/lib/fastlane/actions/latest_testflight_build_number.rb +1 -1
- data/fastlane/lib/fastlane/actions/notarize.rb +4 -0
- data/fastlane/lib/fastlane/actions/onesignal.rb +1 -1
- data/fastlane/lib/fastlane/actions/setup_ci.rb +14 -4
- data/fastlane/lib/fastlane/actions/testfairy.rb +5 -2
- data/fastlane/lib/fastlane/actions/unlock_keychain.rb +6 -1
- data/fastlane/lib/fastlane/version.rb +1 -1
- data/fastlane/swift/Actions.swift +1 -1
- data/fastlane/swift/Appfile.swift +1 -1
- data/fastlane/swift/ArgumentProcessor.swift +1 -1
- data/fastlane/swift/Atomic.swift +1 -1
- data/fastlane/swift/ControlCommand.swift +1 -1
- data/fastlane/swift/Deliverfile.swift +2 -2
- data/fastlane/swift/DeliverfileProtocol.swift +2 -2
- data/fastlane/swift/Fastlane.swift +20 -10
- data/fastlane/swift/Gymfile.swift +2 -2
- data/fastlane/swift/GymfileProtocol.swift +2 -2
- data/fastlane/swift/LaneFileProtocol.swift +1 -1
- data/fastlane/swift/MainProcess.swift +1 -1
- data/fastlane/swift/Matchfile.swift +2 -2
- data/fastlane/swift/MatchfileProtocol.swift +2 -2
- data/fastlane/swift/OptionalConfigValue.swift +1 -1
- data/fastlane/swift/Plugins.swift +1 -1
- data/fastlane/swift/Precheckfile.swift +2 -2
- data/fastlane/swift/PrecheckfileProtocol.swift +2 -2
- data/fastlane/swift/RubyCommand.swift +1 -1
- data/fastlane/swift/RubyCommandable.swift +1 -1
- data/fastlane/swift/Runner.swift +1 -1
- data/fastlane/swift/RunnerArgument.swift +1 -1
- data/fastlane/swift/Scanfile.swift +2 -2
- data/fastlane/swift/ScanfileProtocol.swift +2 -2
- data/fastlane/swift/Screengrabfile.swift +2 -2
- data/fastlane/swift/ScreengrabfileProtocol.swift +2 -2
- data/fastlane/swift/Snapshotfile.swift +2 -2
- data/fastlane/swift/SnapshotfileProtocol.swift +2 -2
- data/fastlane/swift/SocketClient.swift +1 -1
- data/fastlane/swift/SocketClientDelegateProtocol.swift +1 -1
- data/fastlane/swift/SocketResponse.swift +1 -1
- data/fastlane/swift/main.swift +1 -1
- data/fastlane_core/lib/fastlane_core/helper.rb +6 -1
- data/match/lib/assets/READMETemplate.md +2 -2
- data/match/lib/match/generator.rb +2 -2
- data/match/lib/match/runner.rb +1 -1
- data/precheck/lib/precheck/options.rb +1 -1
- data/produce/lib/produce/options.rb +1 -1
- data/spaceship/lib/spaceship/connect_api/models/certificate.rb +1 -0
- data/supply/lib/supply/uploader.rb +19 -12
- data/trainer/lib/trainer/legacy_xcresult.rb +586 -0
- data/trainer/lib/trainer/options.rb +5 -0
- data/trainer/lib/trainer/plist_test_summary_parser.rb +84 -0
- data/trainer/lib/trainer/test_parser.rb +12 -293
- data/trainer/lib/trainer/xcresult/helper.rb +53 -0
- data/trainer/lib/trainer/xcresult/repetition.rb +39 -0
- data/trainer/lib/trainer/xcresult/test_case.rb +221 -0
- data/trainer/lib/trainer/xcresult/test_case_attributes.rb +49 -0
- data/trainer/lib/trainer/xcresult/test_plan.rb +91 -0
- data/trainer/lib/trainer/xcresult/test_suite.rb +134 -0
- data/trainer/lib/trainer/xcresult.rb +31 -388
- data/trainer/lib/trainer.rb +3 -1
- metadata +29 -21
@@ -2,18 +2,17 @@ require 'plist'
|
|
2
2
|
|
3
3
|
require 'fastlane_core/print_table'
|
4
4
|
|
5
|
-
require_relative 'junit_generator'
|
6
5
|
require_relative 'xcresult'
|
6
|
+
require_relative 'xcresult/helper'
|
7
|
+
require_relative 'junit_generator'
|
8
|
+
require_relative 'legacy_xcresult'
|
9
|
+
require_relative 'plist_test_summary_parser'
|
7
10
|
require_relative 'module'
|
8
11
|
|
9
12
|
module Trainer
|
10
13
|
class TestParser
|
11
14
|
attr_accessor :data
|
12
15
|
|
13
|
-
attr_accessor :file_content
|
14
|
-
|
15
|
-
attr_accessor :raw_json
|
16
|
-
|
17
16
|
attr_accessor :number_of_tests
|
18
17
|
attr_accessor :number_of_failures
|
19
18
|
attr_accessor :number_of_tests_excluding_retries
|
@@ -102,15 +101,16 @@ module Trainer
|
|
102
101
|
UI.user_error!("File not found at path '#{path}'") unless File.exist?(path)
|
103
102
|
|
104
103
|
if File.directory?(path) && path.end_with?(".xcresult")
|
105
|
-
|
104
|
+
parser = XCResult::Helper.supports_xcode16_xcresulttool? && !config[:force_legacy_xcresulttool] ? XCResult::Parser : LegacyXCResult::Parser
|
105
|
+
self.data = parser.parse_xcresult(path: path, output_remove_retry_attempts: config[:output_remove_retry_attempts])
|
106
106
|
else
|
107
|
-
|
108
|
-
|
107
|
+
file_content = File.read(path)
|
108
|
+
raw_json = Plist.parse_xml(file_content)
|
109
109
|
|
110
|
-
return if
|
110
|
+
return if raw_json["FormatVersion"].to_s.length.zero? # maybe that's a useless plist file
|
111
111
|
|
112
|
-
ensure_file_valid!
|
113
|
-
parse_content(config[:xcpretty_naming])
|
112
|
+
PlistTestSummaryParser.ensure_file_valid!(raw_json)
|
113
|
+
self.data = PlistTestSummaryParser.parse_content(raw_json, config[:xcpretty_naming])
|
114
114
|
end
|
115
115
|
|
116
116
|
self.number_of_tests = 0
|
@@ -131,293 +131,12 @@ module Trainer
|
|
131
131
|
|
132
132
|
# Returns the JUnit report as String
|
133
133
|
def to_junit
|
134
|
-
JunitGenerator.new(self.data).generate
|
134
|
+
self.data.kind_of?(Trainer::XCResult::TestPlan) ? self.data.to_xml : JunitGenerator.new(self.data).generate
|
135
135
|
end
|
136
136
|
|
137
137
|
# @return [Bool] were all tests successful? Is false if at least one test failed
|
138
138
|
def tests_successful?
|
139
139
|
self.data.collect { |a| a[:number_of_failures_excluding_retries] }.all?(&:zero?)
|
140
140
|
end
|
141
|
-
|
142
|
-
private
|
143
|
-
|
144
|
-
def ensure_file_valid!
|
145
|
-
format_version = self.raw_json["FormatVersion"]
|
146
|
-
supported_versions = ["1.1", "1.2"]
|
147
|
-
UI.user_error!("Format version '#{format_version}' is not supported, must be #{supported_versions.join(', ')}") unless supported_versions.include?(format_version)
|
148
|
-
end
|
149
|
-
|
150
|
-
# Converts the raw plist test structure into something that's easier to enumerate
|
151
|
-
def unfold_tests(data)
|
152
|
-
# `data` looks like this
|
153
|
-
# => [{"Subtests"=>
|
154
|
-
# [{"Subtests"=>
|
155
|
-
# [{"Subtests"=>
|
156
|
-
# [{"Duration"=>0.4,
|
157
|
-
# "TestIdentifier"=>"Unit/testExample()",
|
158
|
-
# "TestName"=>"testExample()",
|
159
|
-
# "TestObjectClass"=>"IDESchemeActionTestSummary",
|
160
|
-
# "TestStatus"=>"Success",
|
161
|
-
# "TestSummaryGUID"=>"4A24BFED-03E6-4FBE-BC5E-2D80023C06B4"},
|
162
|
-
# {"FailureSummaries"=>
|
163
|
-
# [{"FileName"=>"/Users/krausefx/Developer/themoji/Unit/Unit.swift",
|
164
|
-
# "LineNumber"=>34,
|
165
|
-
# "Message"=>"XCTAssertTrue failed - ",
|
166
|
-
# "PerformanceFailure"=>false}],
|
167
|
-
# "TestIdentifier"=>"Unit/testExample2()",
|
168
|
-
|
169
|
-
tests = []
|
170
|
-
data.each do |current_hash|
|
171
|
-
if current_hash["Subtests"]
|
172
|
-
tests += unfold_tests(current_hash["Subtests"])
|
173
|
-
end
|
174
|
-
if current_hash["TestStatus"]
|
175
|
-
tests << current_hash
|
176
|
-
end
|
177
|
-
end
|
178
|
-
return tests
|
179
|
-
end
|
180
|
-
|
181
|
-
# Returns the test group and test name from the passed summary and test
|
182
|
-
# Pass xcpretty_naming = true to get the test naming aligned with xcpretty
|
183
|
-
def test_group_and_name(testable_summary, test, xcpretty_naming)
|
184
|
-
if xcpretty_naming
|
185
|
-
group = testable_summary["TargetName"] + "." + test["TestIdentifier"].split("/")[0..-2].join(".")
|
186
|
-
name = test["TestName"][0..-3]
|
187
|
-
else
|
188
|
-
group = test["TestIdentifier"].split("/")[0..-2].join(".")
|
189
|
-
name = test["TestName"]
|
190
|
-
end
|
191
|
-
return group, name
|
192
|
-
end
|
193
|
-
|
194
|
-
def execute_cmd(cmd)
|
195
|
-
output = `#{cmd}`
|
196
|
-
raise "Failed to execute - #{cmd}" unless $?.success?
|
197
|
-
return output
|
198
|
-
end
|
199
|
-
|
200
|
-
# Hotfix: From Xcode 16 beta 3 'xcresulttool get --format json' has been deprecated;
|
201
|
-
# '--legacy' flag required to keep on using the command
|
202
|
-
def generate_cmd_parse_xcresult(path)
|
203
|
-
xcresulttool_cmd = %W(
|
204
|
-
xcrun
|
205
|
-
xcresulttool
|
206
|
-
get
|
207
|
-
--format
|
208
|
-
json
|
209
|
-
--path
|
210
|
-
#{path}
|
211
|
-
)
|
212
|
-
|
213
|
-
# e.g. DEVELOPER_DIR=/Applications/Xcode_16_beta_3.app
|
214
|
-
# xcresulttool version 23021, format version 3.53 (current)
|
215
|
-
match = `xcrun xcresulttool version`.match(/xcresulttool version (?<version>[\d.]+)/)
|
216
|
-
version = match[:version]
|
217
|
-
xcresulttool_cmd << '--legacy' if Gem::Version.new(version) >= Gem::Version.new(23_021)
|
218
|
-
|
219
|
-
xcresulttool_cmd.join(' ')
|
220
|
-
end
|
221
|
-
|
222
|
-
def parse_xcresult(path, output_remove_retry_attempts: false)
|
223
|
-
require 'shellwords'
|
224
|
-
path = Shellwords.escape(path)
|
225
|
-
|
226
|
-
# Executes xcresulttool to get JSON format of the result bundle object
|
227
|
-
# Hotfix: From Xcode 16 beta 3 'xcresulttool get --format json' has been deprecated; '--legacy' flag required to keep on using the command
|
228
|
-
xcresulttool_cmd = generate_cmd_parse_xcresult(path)
|
229
|
-
|
230
|
-
result_bundle_object_raw = execute_cmd(xcresulttool_cmd)
|
231
|
-
result_bundle_object = JSON.parse(result_bundle_object_raw)
|
232
|
-
|
233
|
-
# Parses JSON into ActionsInvocationRecord to find a list of all ids for ActionTestPlanRunSummaries
|
234
|
-
actions_invocation_record = Trainer::XCResult::ActionsInvocationRecord.new(result_bundle_object)
|
235
|
-
test_refs = actions_invocation_record.actions.map do |action|
|
236
|
-
action.action_result.tests_ref
|
237
|
-
end.compact
|
238
|
-
ids = test_refs.map(&:id)
|
239
|
-
|
240
|
-
# Maps ids into ActionTestPlanRunSummaries by executing xcresulttool to get JSON
|
241
|
-
# containing specific information for each test summary,
|
242
|
-
summaries = ids.map do |id|
|
243
|
-
raw = execute_cmd("#{xcresulttool_cmd} --id #{id}")
|
244
|
-
json = JSON.parse(raw)
|
245
|
-
Trainer::XCResult::ActionTestPlanRunSummaries.new(json)
|
246
|
-
end
|
247
|
-
|
248
|
-
# Converts the ActionTestPlanRunSummaries to data for junit generator
|
249
|
-
failures = actions_invocation_record.issues.test_failure_summaries || []
|
250
|
-
summaries_to_data(summaries, failures, output_remove_retry_attempts: output_remove_retry_attempts)
|
251
|
-
end
|
252
|
-
|
253
|
-
def summaries_to_data(summaries, failures, output_remove_retry_attempts: false)
|
254
|
-
# Gets flat list of all ActionTestableSummary
|
255
|
-
all_summaries = summaries.map(&:summaries).flatten
|
256
|
-
testable_summaries = all_summaries.map(&:testable_summaries).flatten
|
257
|
-
|
258
|
-
summaries_to_names = test_summaries_to_configuration_names(all_summaries)
|
259
|
-
|
260
|
-
# Maps ActionTestableSummary to rows for junit generator
|
261
|
-
rows = testable_summaries.map do |testable_summary|
|
262
|
-
all_tests = testable_summary.all_tests.flatten
|
263
|
-
|
264
|
-
# Used by store number of passes and failures by identifier
|
265
|
-
# This is used when Xcode 13 (and up) retries tests
|
266
|
-
# The identifier is duplicated until test succeeds or max count is reached
|
267
|
-
tests_by_identifier = {}
|
268
|
-
|
269
|
-
test_rows = all_tests.map do |test|
|
270
|
-
identifier = "#{test.parent.name}.#{test.name}"
|
271
|
-
test_row = {
|
272
|
-
identifier: identifier,
|
273
|
-
name: test.name,
|
274
|
-
duration: test.duration,
|
275
|
-
status: test.test_status,
|
276
|
-
test_group: test.parent.name,
|
277
|
-
|
278
|
-
# These don't map to anything but keeping empty strings
|
279
|
-
guid: ""
|
280
|
-
}
|
281
|
-
|
282
|
-
info = tests_by_identifier[identifier] || {}
|
283
|
-
info[:failure_count] ||= 0
|
284
|
-
info[:skip_count] ||= 0
|
285
|
-
info[:success_count] ||= 0
|
286
|
-
|
287
|
-
retry_count = info[:retry_count]
|
288
|
-
if retry_count.nil?
|
289
|
-
retry_count = 0
|
290
|
-
else
|
291
|
-
retry_count += 1
|
292
|
-
end
|
293
|
-
info[:retry_count] = retry_count
|
294
|
-
|
295
|
-
# Set failure message if failure found
|
296
|
-
failure = test.find_failure(failures)
|
297
|
-
if failure
|
298
|
-
test_row[:failures] = [{
|
299
|
-
file_name: "",
|
300
|
-
line_number: 0,
|
301
|
-
message: "",
|
302
|
-
performance_failure: {},
|
303
|
-
failure_message: failure.failure_message
|
304
|
-
}]
|
305
|
-
|
306
|
-
info[:failure_count] += 1
|
307
|
-
elsif test.test_status == "Skipped"
|
308
|
-
test_row[:skipped] = true
|
309
|
-
info[:skip_count] += 1
|
310
|
-
else
|
311
|
-
info[:success_count] = 1
|
312
|
-
end
|
313
|
-
|
314
|
-
tests_by_identifier[identifier] = info
|
315
|
-
|
316
|
-
test_row
|
317
|
-
end
|
318
|
-
|
319
|
-
# Remove retry attempts from the count and test rows
|
320
|
-
if output_remove_retry_attempts
|
321
|
-
test_rows = test_rows.reject do |test_row|
|
322
|
-
remove = false
|
323
|
-
|
324
|
-
identifier = test_row[:identifier]
|
325
|
-
info = tests_by_identifier[identifier]
|
326
|
-
|
327
|
-
# Remove if this row is a retry and is a failure
|
328
|
-
if info[:retry_count] > 0
|
329
|
-
remove = !(test_row[:failures] || []).empty?
|
330
|
-
end
|
331
|
-
|
332
|
-
# Remove all failure and retry count if test did eventually pass
|
333
|
-
if remove
|
334
|
-
info[:failure_count] -= 1
|
335
|
-
info[:retry_count] -= 1
|
336
|
-
tests_by_identifier[identifier] = info
|
337
|
-
end
|
338
|
-
|
339
|
-
remove
|
340
|
-
end
|
341
|
-
end
|
342
|
-
|
343
|
-
row = {
|
344
|
-
project_path: testable_summary.project_relative_path,
|
345
|
-
target_name: testable_summary.target_name,
|
346
|
-
test_name: testable_summary.name,
|
347
|
-
configuration_name: summaries_to_names[testable_summary],
|
348
|
-
duration: all_tests.map(&:duration).inject(:+),
|
349
|
-
tests: test_rows
|
350
|
-
}
|
351
|
-
|
352
|
-
row[:number_of_tests] = row[:tests].count
|
353
|
-
row[:number_of_failures] = row[:tests].find_all { |a| (a[:failures] || []).count > 0 }.count
|
354
|
-
|
355
|
-
# Used for seeing if any tests continued to fail after all of the Xcode 13 (and up) retries have finished
|
356
|
-
unique_tests = tests_by_identifier.values || []
|
357
|
-
row[:number_of_tests_excluding_retries] = unique_tests.count
|
358
|
-
row[:number_of_skipped] = unique_tests.map { |a| a[:skip_count] }.inject(:+)
|
359
|
-
row[:number_of_failures_excluding_retries] = unique_tests.find_all { |a| (a[:success_count] + a[:skip_count]) == 0 }.count
|
360
|
-
row[:number_of_retries] = unique_tests.map { |a| a[:retry_count] }.inject(:+)
|
361
|
-
|
362
|
-
row
|
363
|
-
end
|
364
|
-
|
365
|
-
self.data = rows
|
366
|
-
end
|
367
|
-
|
368
|
-
def test_summaries_to_configuration_names(test_summaries)
|
369
|
-
summary_to_name = {}
|
370
|
-
test_summaries.each do |summary|
|
371
|
-
summary.testable_summaries.each do |testable_summary|
|
372
|
-
summary_to_name[testable_summary] = summary.name
|
373
|
-
end
|
374
|
-
end
|
375
|
-
summary_to_name
|
376
|
-
end
|
377
|
-
|
378
|
-
# Convert the Hashes and Arrays in something more useful
|
379
|
-
def parse_content(xcpretty_naming)
|
380
|
-
self.data = self.raw_json["TestableSummaries"].collect do |testable_summary|
|
381
|
-
summary_row = {
|
382
|
-
project_path: testable_summary["ProjectPath"],
|
383
|
-
target_name: testable_summary["TargetName"],
|
384
|
-
test_name: testable_summary["TestName"],
|
385
|
-
duration: testable_summary["Tests"].map { |current_test| current_test["Duration"] }.inject(:+),
|
386
|
-
tests: unfold_tests(testable_summary["Tests"]).collect do |current_test|
|
387
|
-
test_group, test_name = test_group_and_name(testable_summary, current_test, xcpretty_naming)
|
388
|
-
current_row = {
|
389
|
-
identifier: current_test["TestIdentifier"],
|
390
|
-
test_group: test_group,
|
391
|
-
name: test_name,
|
392
|
-
object_class: current_test["TestObjectClass"],
|
393
|
-
status: current_test["TestStatus"],
|
394
|
-
guid: current_test["TestSummaryGUID"],
|
395
|
-
duration: current_test["Duration"]
|
396
|
-
}
|
397
|
-
if current_test["FailureSummaries"]
|
398
|
-
current_row[:failures] = current_test["FailureSummaries"].collect do |current_failure|
|
399
|
-
{
|
400
|
-
file_name: current_failure['FileName'],
|
401
|
-
line_number: current_failure['LineNumber'],
|
402
|
-
message: current_failure['Message'],
|
403
|
-
performance_failure: current_failure['PerformanceFailure'],
|
404
|
-
failure_message: "#{current_failure['Message']} (#{current_failure['FileName']}:#{current_failure['LineNumber']})"
|
405
|
-
}
|
406
|
-
end
|
407
|
-
end
|
408
|
-
current_row
|
409
|
-
end
|
410
|
-
}
|
411
|
-
summary_row[:number_of_tests] = summary_row[:tests].count
|
412
|
-
summary_row[:number_of_failures] = summary_row[:tests].find_all { |a| (a[:failures] || []).count > 0 }.count
|
413
|
-
|
414
|
-
# Makes sure that plist support matches data output of xcresult
|
415
|
-
summary_row[:number_of_tests_excluding_retries] = summary_row[:number_of_tests]
|
416
|
-
summary_row[:number_of_failures_excluding_retries] = summary_row[:number_of_failures]
|
417
|
-
summary_row[:number_of_retries] = 0
|
418
|
-
|
419
|
-
summary_row
|
420
|
-
end
|
421
|
-
end
|
422
141
|
end
|
423
142
|
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'rexml/document'
|
2
|
+
require 'rubygems'
|
3
|
+
|
4
|
+
module Trainer
|
5
|
+
module XCResult
|
6
|
+
# Helper class for XML and node operations
|
7
|
+
class Helper
|
8
|
+
# Creates an XML element with the given name and attributes
|
9
|
+
#
|
10
|
+
# @param name [String] The name of the XML element
|
11
|
+
# @param attributes [Hash] A hash of attributes to add to the element
|
12
|
+
# @return [REXML::Element] The created XML element
|
13
|
+
def self.create_xml_element(name, **attributes)
|
14
|
+
# Sanitize invalid XML characters (control chars except tab/CR/LF) to avoid errors when generating XML
|
15
|
+
sanitizer = proc { |text| text.to_s.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/) { |c| format("\\u%04x", c.ord) } }
|
16
|
+
element = REXML::Element.new(sanitizer.call(name))
|
17
|
+
attributes.compact.each do |key, value|
|
18
|
+
safe_value = sanitizer.call(value.to_s)
|
19
|
+
element.attributes[key.to_s] = safe_value
|
20
|
+
end
|
21
|
+
element
|
22
|
+
end
|
23
|
+
|
24
|
+
# Find children of a node by specified node types
|
25
|
+
#
|
26
|
+
# @param node [Hash, nil] The JSON node to search within
|
27
|
+
# @param node_types [Array<String>] The node types to filter by
|
28
|
+
# @return [Array<Hash>] Array of child nodes matching the specified types
|
29
|
+
def self.find_json_children(node, *node_types)
|
30
|
+
return [] if node.nil? || node['children'].nil?
|
31
|
+
|
32
|
+
node['children'].select { |child| node_types.include?(child['nodeType']) }
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check if the current xcresulttool supports new commands introduced in Xcode 16+
|
36
|
+
#
|
37
|
+
# Since Xcode 16b3, xcresulttool has marked `get <object> --format json` as deprecated/legacy,
|
38
|
+
# and replaced it with `xcrun xcresulttool get test-results tests` instead.
|
39
|
+
#
|
40
|
+
# @return [Boolean] Whether the xcresulttool supports Xcode 16+ commands
|
41
|
+
def self.supports_xcode16_xcresulttool?
|
42
|
+
# e.g. DEVELOPER_DIR=/Applications/Xcode_16_beta_3.app
|
43
|
+
# xcresulttool version 23021, format version 3.53 (current)
|
44
|
+
match = `xcrun xcresulttool version`.match(/xcresulttool version (?<version>[\d.]+)/)
|
45
|
+
version = match[:version]
|
46
|
+
|
47
|
+
Gem::Version.new(version) >= Gem::Version.new(23_021)
|
48
|
+
rescue
|
49
|
+
false
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require_relative 'test_case_attributes'
|
2
|
+
|
3
|
+
module Trainer
|
4
|
+
module XCResult
|
5
|
+
# Represents retries of a test case, including the original run
|
6
|
+
# e.g. a test case that ran 3 times in total will be represented by 3 Repetition instances in the `xcresulttool` JSON output,
|
7
|
+
# one for the original run and one for the 2 retries.
|
8
|
+
class Repetition
|
9
|
+
include TestCaseAttributes
|
10
|
+
|
11
|
+
attr_reader :name
|
12
|
+
attr_reader :duration
|
13
|
+
attr_reader :result
|
14
|
+
attr_reader :failure_messages
|
15
|
+
attr_reader :source_references
|
16
|
+
attr_reader :attachments
|
17
|
+
|
18
|
+
def initialize(name:, duration:, result:, failure_messages: [], source_references: [], attachments: [])
|
19
|
+
@name = name
|
20
|
+
@duration = duration
|
21
|
+
@result = result
|
22
|
+
@failure_messages = failure_messages
|
23
|
+
@source_references = source_references
|
24
|
+
@attachments = attachments
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.from_json(node:)
|
28
|
+
new(
|
29
|
+
name: node['name'],
|
30
|
+
duration: parse_duration(node['duration']),
|
31
|
+
result: node['result'],
|
32
|
+
failure_messages: extract_failure_messages(node),
|
33
|
+
source_references: extract_source_references(node),
|
34
|
+
attachments: extract_attachments(node)
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
require_relative 'helper'
|
2
|
+
require_relative 'test_case_attributes'
|
3
|
+
require_relative 'repetition'
|
4
|
+
|
5
|
+
module Trainer
|
6
|
+
module XCResult
|
7
|
+
# Represents a test case, including its retries (aka repetitions)
|
8
|
+
class TestCase
|
9
|
+
include TestCaseAttributes
|
10
|
+
|
11
|
+
attr_reader :name
|
12
|
+
attr_reader :identifier
|
13
|
+
attr_reader :duration
|
14
|
+
attr_reader :result
|
15
|
+
attr_reader :classname
|
16
|
+
attr_reader :argument
|
17
|
+
# @return [Array<Repetition>] Array of retry attempts for this test case, **including the initial attempt**
|
18
|
+
# This will be `nil` if the test case was not run multiple times, but will contain all repetitions if it was run more than once.
|
19
|
+
attr_reader :retries
|
20
|
+
attr_reader :failure_messages
|
21
|
+
attr_reader :source_references
|
22
|
+
attr_reader :attachments
|
23
|
+
attr_reader :tags
|
24
|
+
|
25
|
+
def initialize(
|
26
|
+
name:, identifier:, duration:, result:, classname:, argument: nil, tags: [], retries: nil,
|
27
|
+
failure_messages: [], source_references: [], attachments: []
|
28
|
+
)
|
29
|
+
@name = name
|
30
|
+
@identifier = identifier
|
31
|
+
@duration = duration
|
32
|
+
@result = result
|
33
|
+
@classname = classname
|
34
|
+
@argument = argument
|
35
|
+
@tags = tags
|
36
|
+
@retries = retries
|
37
|
+
@failure_messages = failure_messages
|
38
|
+
@source_references = source_references
|
39
|
+
@attachments = attachments
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.from_json(node:)
|
43
|
+
# Handle test case arguments
|
44
|
+
argument_nodes = Helper.find_json_children(node, 'Arguments')
|
45
|
+
argument_nodes = [nil] if argument_nodes.empty?
|
46
|
+
|
47
|
+
# Generate test cases for each argument
|
48
|
+
argument_nodes.map do |arg_node|
|
49
|
+
# For repetition nodes, failure messages, source refs, attachments and result attributes,
|
50
|
+
# Search them as children of the argument child node if present, of the test case node otherwise.
|
51
|
+
node_for_attributes = arg_node || node
|
52
|
+
|
53
|
+
retries = Helper.find_json_children(node_for_attributes, 'Repetition', 'Test Case Run')
|
54
|
+
&.map { |rep_node| Repetition.from_json(node: rep_node) } || []
|
55
|
+
|
56
|
+
failure_messages = if retries.empty?
|
57
|
+
extract_failure_messages(node_for_attributes)
|
58
|
+
else
|
59
|
+
retries.flat_map(&:failure_messages)
|
60
|
+
end
|
61
|
+
|
62
|
+
source_references = if retries.empty?
|
63
|
+
extract_source_references(node_for_attributes)
|
64
|
+
else
|
65
|
+
retries.flat_map(&:source_references)
|
66
|
+
end
|
67
|
+
|
68
|
+
attachments = if retries.empty?
|
69
|
+
extract_attachments(node_for_attributes)
|
70
|
+
else
|
71
|
+
retries.flat_map(&:attachments)
|
72
|
+
end
|
73
|
+
|
74
|
+
new(
|
75
|
+
name: node['name'],
|
76
|
+
identifier: node['nodeIdentifier'],
|
77
|
+
duration: parse_duration(node['duration']),
|
78
|
+
result: node_for_attributes['result'],
|
79
|
+
classname: extract_classname(node),
|
80
|
+
argument: arg_node&.[]('name'), # Only set if there is an argument
|
81
|
+
tags: node['tags'] || [],
|
82
|
+
retries: retries,
|
83
|
+
failure_messages: failure_messages,
|
84
|
+
source_references: source_references,
|
85
|
+
attachments: attachments
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Generates XML nodes for the test case
|
91
|
+
#
|
92
|
+
# @return [Array<REXML::Element>] An array of XML <testcase> elements
|
93
|
+
#
|
94
|
+
# - If no retries, the array contains a single <testcase> element
|
95
|
+
# - If retries, the array contains one <testcase> element per retry
|
96
|
+
def to_xml_nodes
|
97
|
+
runs = @retries.nil? || @retries.empty? ? [nil] : @retries
|
98
|
+
|
99
|
+
runs.map do |run|
|
100
|
+
Helper.create_xml_element('testcase',
|
101
|
+
name: if @argument.nil?
|
102
|
+
@name
|
103
|
+
else
|
104
|
+
@name.match?(/(\(.*\))/) ? @name.gsub(/(\(.*\))/, "(#{@argument})") : "#{@name} (#{@argument})"
|
105
|
+
end,
|
106
|
+
classname: @classname,
|
107
|
+
time: (run || self).duration.to_s).tap do |testcase|
|
108
|
+
add_xml_result_elements(testcase, run || self)
|
109
|
+
add_properties_to_xml(testcase, repetition_name: run&.name)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def retries_count
|
115
|
+
@retries&.count || 0
|
116
|
+
end
|
117
|
+
|
118
|
+
def total_tests_count
|
119
|
+
retries_count > 0 ? retries_count : 1
|
120
|
+
end
|
121
|
+
|
122
|
+
def total_failures_count
|
123
|
+
if retries_count > 0
|
124
|
+
@retries.count(&:failed?)
|
125
|
+
elsif failed?
|
126
|
+
1
|
127
|
+
else
|
128
|
+
0
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def self.extract_classname(node)
|
133
|
+
return nil if node['nodeIdentifier'].nil?
|
134
|
+
|
135
|
+
parts = node['nodeIdentifier'].split('/')
|
136
|
+
parts[0...-1].join('.')
|
137
|
+
end
|
138
|
+
private_class_method :extract_classname
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
# Adds <properties> element to the XML <testcase> element
|
143
|
+
#
|
144
|
+
# @param testcase [REXML::Element] The XML testcase element to add properties to
|
145
|
+
# @param repetition_name [String, nil] Name of the retry attempt, if this is a retry
|
146
|
+
#
|
147
|
+
# Properties added:
|
148
|
+
# - if argument is present:
|
149
|
+
# - `testname`: Raw test name (as in such case, <testcase name="…"> would contain a mix of the test name and the argument)
|
150
|
+
# - `argument`: Test argument value
|
151
|
+
# - `repetitionN`: Name of the retry attempt if present
|
152
|
+
# - `source_referenceN`: Source code references (file/line) for failures
|
153
|
+
# - `attachmentN`: Test attachments like screenshots
|
154
|
+
# - `tagN`: Test tags/categories
|
155
|
+
#
|
156
|
+
# <properties> element is only added to the XML if at least one property exists
|
157
|
+
def add_properties_to_xml(testcase, repetition_name: nil)
|
158
|
+
properties = Helper.create_xml_element('properties')
|
159
|
+
|
160
|
+
# Add argument as property
|
161
|
+
if @argument
|
162
|
+
name_prop = Helper.create_xml_element('property', name: "testname", value: @name)
|
163
|
+
properties.add_element(name_prop)
|
164
|
+
prop = Helper.create_xml_element('property', name: "argument", value: @argument)
|
165
|
+
properties.add_element(prop)
|
166
|
+
end
|
167
|
+
|
168
|
+
# Add repetition as property
|
169
|
+
if repetition_name
|
170
|
+
prop = Helper.create_xml_element('property', name: "repetition", value: repetition_name)
|
171
|
+
properties.add_element(prop)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Add source references as properties
|
175
|
+
(@source_references || []).each_with_index do |ref, index|
|
176
|
+
prop = Helper.create_xml_element('property', name: "source_reference#{index + 1}", value: ref)
|
177
|
+
properties.add_element(prop)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Add attachments as properties
|
181
|
+
(@attachments || []).each_with_index do |attachment, index|
|
182
|
+
prop = Helper.create_xml_element('property', name: "attachment#{index + 1}", value: attachment)
|
183
|
+
properties.add_element(prop)
|
184
|
+
end
|
185
|
+
|
186
|
+
# Add tags as properties
|
187
|
+
(@tags || []).sort.each_with_index do |tag, index|
|
188
|
+
prop = Helper.create_xml_element('property', name: "tag#{index + 1}", value: tag)
|
189
|
+
properties.add_element(prop)
|
190
|
+
end
|
191
|
+
|
192
|
+
# Only add properties to testcase if it has child elements
|
193
|
+
testcase.add_element(properties) if properties.elements.any?
|
194
|
+
end
|
195
|
+
|
196
|
+
# Adds <failure> and <skipped> elements to the XML <testcase> element based on test status
|
197
|
+
#
|
198
|
+
# @param testcase [REXML::Element] The XML testcase element to add result elements to
|
199
|
+
# @param test_obj [Repetition, TestCase] Object representing the test result
|
200
|
+
# This can be either a Repetition object or the TestCase itself.
|
201
|
+
# Must respond to the following methods:
|
202
|
+
# - failed? [Boolean]: Indicates if the test failed
|
203
|
+
# - skipped? [Boolean]: Indicates if the test was skipped
|
204
|
+
# - failure_messages [Array<String>, nil]: List of failure messages (optional)
|
205
|
+
#
|
206
|
+
# Adds:
|
207
|
+
# - <failure> elements with messages for failed tests
|
208
|
+
# - <skipped> element for skipped tests
|
209
|
+
# - No elements added for passed tests
|
210
|
+
def add_xml_result_elements(testcase, test_obj)
|
211
|
+
if test_obj.failed?
|
212
|
+
(test_obj.failure_messages&.any? ? test_obj.failure_messages : [nil]).each do |msg|
|
213
|
+
testcase.add_element(Helper.create_xml_element('failure', message: msg))
|
214
|
+
end
|
215
|
+
elsif test_obj.skipped?
|
216
|
+
testcase.add_element(Helper.create_xml_element('skipped', message: test_obj.failure_messages&.first))
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|