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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -96
  3. data/cert/lib/cert/options.rb +7 -2
  4. data/cert/lib/cert/runner.rb +23 -11
  5. data/deliver/lib/deliver/options.rb +1 -1
  6. data/fastlane/lib/fastlane/actions/app_store_build_number.rb +1 -1
  7. data/fastlane/lib/fastlane/actions/docs/sync_code_signing.md +1 -1
  8. data/fastlane/lib/fastlane/actions/latest_testflight_build_number.rb +1 -1
  9. data/fastlane/lib/fastlane/actions/notarize.rb +4 -0
  10. data/fastlane/lib/fastlane/actions/onesignal.rb +1 -1
  11. data/fastlane/lib/fastlane/actions/setup_ci.rb +14 -4
  12. data/fastlane/lib/fastlane/actions/testfairy.rb +5 -2
  13. data/fastlane/lib/fastlane/actions/unlock_keychain.rb +6 -1
  14. data/fastlane/lib/fastlane/version.rb +1 -1
  15. data/fastlane/swift/Actions.swift +1 -1
  16. data/fastlane/swift/Appfile.swift +1 -1
  17. data/fastlane/swift/ArgumentProcessor.swift +1 -1
  18. data/fastlane/swift/Atomic.swift +1 -1
  19. data/fastlane/swift/ControlCommand.swift +1 -1
  20. data/fastlane/swift/Deliverfile.swift +2 -2
  21. data/fastlane/swift/DeliverfileProtocol.swift +2 -2
  22. data/fastlane/swift/Fastlane.swift +20 -10
  23. data/fastlane/swift/Gymfile.swift +2 -2
  24. data/fastlane/swift/GymfileProtocol.swift +2 -2
  25. data/fastlane/swift/LaneFileProtocol.swift +1 -1
  26. data/fastlane/swift/MainProcess.swift +1 -1
  27. data/fastlane/swift/Matchfile.swift +2 -2
  28. data/fastlane/swift/MatchfileProtocol.swift +2 -2
  29. data/fastlane/swift/OptionalConfigValue.swift +1 -1
  30. data/fastlane/swift/Plugins.swift +1 -1
  31. data/fastlane/swift/Precheckfile.swift +2 -2
  32. data/fastlane/swift/PrecheckfileProtocol.swift +2 -2
  33. data/fastlane/swift/RubyCommand.swift +1 -1
  34. data/fastlane/swift/RubyCommandable.swift +1 -1
  35. data/fastlane/swift/Runner.swift +1 -1
  36. data/fastlane/swift/RunnerArgument.swift +1 -1
  37. data/fastlane/swift/Scanfile.swift +2 -2
  38. data/fastlane/swift/ScanfileProtocol.swift +2 -2
  39. data/fastlane/swift/Screengrabfile.swift +2 -2
  40. data/fastlane/swift/ScreengrabfileProtocol.swift +2 -2
  41. data/fastlane/swift/Snapshotfile.swift +2 -2
  42. data/fastlane/swift/SnapshotfileProtocol.swift +2 -2
  43. data/fastlane/swift/SocketClient.swift +1 -1
  44. data/fastlane/swift/SocketClientDelegateProtocol.swift +1 -1
  45. data/fastlane/swift/SocketResponse.swift +1 -1
  46. data/fastlane/swift/main.swift +1 -1
  47. data/fastlane_core/lib/fastlane_core/helper.rb +6 -1
  48. data/match/lib/assets/READMETemplate.md +2 -2
  49. data/match/lib/match/generator.rb +2 -2
  50. data/match/lib/match/runner.rb +1 -1
  51. data/precheck/lib/precheck/options.rb +1 -1
  52. data/produce/lib/produce/options.rb +1 -1
  53. data/spaceship/lib/spaceship/connect_api/models/certificate.rb +1 -0
  54. data/supply/lib/supply/uploader.rb +19 -12
  55. data/trainer/lib/trainer/legacy_xcresult.rb +586 -0
  56. data/trainer/lib/trainer/options.rb +5 -0
  57. data/trainer/lib/trainer/plist_test_summary_parser.rb +84 -0
  58. data/trainer/lib/trainer/test_parser.rb +12 -293
  59. data/trainer/lib/trainer/xcresult/helper.rb +53 -0
  60. data/trainer/lib/trainer/xcresult/repetition.rb +39 -0
  61. data/trainer/lib/trainer/xcresult/test_case.rb +221 -0
  62. data/trainer/lib/trainer/xcresult/test_case_attributes.rb +49 -0
  63. data/trainer/lib/trainer/xcresult/test_plan.rb +91 -0
  64. data/trainer/lib/trainer/xcresult/test_suite.rb +134 -0
  65. data/trainer/lib/trainer/xcresult.rb +31 -388
  66. data/trainer/lib/trainer.rb +3 -1
  67. 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
- parse_xcresult(path, output_remove_retry_attempts: config[:output_remove_retry_attempts])
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
- self.file_content = File.read(path)
108
- self.raw_json = Plist.parse_xml(self.file_content)
107
+ file_content = File.read(path)
108
+ raw_json = Plist.parse_xml(file_content)
109
109
 
110
- return if self.raw_json["FormatVersion"].to_s.length.zero? # maybe that's a useless plist file
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