File: Event.JUnitXMLRecorder.swift

package info (click to toggle)
swiftlang 6.0.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,519,992 kB
  • sloc: cpp: 9,107,863; ansic: 2,040,022; asm: 1,135,751; python: 296,500; objc: 82,456; f90: 60,502; lisp: 34,951; pascal: 19,946; sh: 18,133; perl: 7,482; ml: 4,937; javascript: 4,117; makefile: 3,840; awk: 3,535; xml: 914; fortran: 619; cs: 573; ruby: 573
file content (263 lines) | stat: -rw-r--r-- 9,238 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

extension Event {
  /// A type which handles ``Event`` instances and outputs representations of
  /// them as JUnit-compatible XML.
  ///
  /// The maintainers of JUnit do not publish a formal XML schema. A _de facto_
  /// schema is described in the [JUnit repository](https://github.com/junit-team/junit5/blob/main/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java).
  @_spi(ForToolsIntegrationOnly)
  public struct JUnitXMLRecorder: Sendable/*, ~Copyable*/ {
    /// The write function for this event recorder.
    var write: @Sendable (String) -> Void

    /// A type that contains mutable context for ``Event/JUnitXMLRecorder``.
    ///
    /// - Bug: Although the data being tracked is different, this type could
    ///   potentially be reconciled with
    ///   ``Event/ConsoleOutputRecorder/Context``.
    private struct _Context: Sendable {
      /// The instant at which the run started.
      var runStartInstant: Test.Clock.Instant?

      /// The number of tests started or skipped during the run.
      ///
      /// This value does not include test suites.
      var testCount = 0

      /// Any recorded issues where the test was not known.
      var issuesForUnknownTests = [Issue]()

      /// A type describing data tracked on a per-test basis.
      struct TestData: Sendable {
        /// The ID of the test.
        var id: Test.ID

        /// The instant at which the test started.
        var startInstant: Test.Clock.Instant

        /// The instant at which the test started.
        var endInstant: Test.Clock.Instant?

        /// Any issues recorded for the test.
        var issues = [Issue]()

        /// Information about the test if it was skipped.
        var skipInfo: SkipInfo?
      }

      /// Data tracked on a per-test basis.
      var testData = Graph<String, TestData?>()
    }

    /// This event recorder's mutable context about events it has received,
    /// which may be used to inform how subsequent events are written.
    private var _context = Locked(rawValue: _Context())

    /// Initialize a new event recorder.
    ///
    /// - Parameters:
    ///   - write: A closure that writes output to its destination. The closure
    ///     may be invoked concurrently.
    ///
    /// Output from the testing library is written using `write`.
    init(writingUsing write: @escaping @Sendable (String) -> Void) {
      self.write = write
    }
  }
}

extension Event.JUnitXMLRecorder {
  /// Record the specified event by generating a representation of it as a
  /// human-readable string.
  ///
  /// - Parameters:
  ///   - event: The event to record.
  ///   - eventContext: The context associated with the event.
  ///
  /// - Returns: A string description of the event, or `nil` if there is nothing
  ///   useful to output for this event.
  private func _record(_ event: borrowing Event, in eventContext: borrowing Event.Context) -> String? {
    let instant = event.instant
    let test = eventContext.test

    switch event.kind {
    case .runStarted:
      _context.withLock { context in
        context.runStartInstant = instant
      }
      return #"""
        <?xml version="1.0" encoding="UTF-8"?>
        <testsuites>

        """#
    case .testStarted where false == test?.isSuite:
      let id = test!.id
      let keyPath = id.keyPathRepresentation
      _context.withLock { context in
        context.testCount += 1
        context.testData[keyPath] = _Context.TestData(id: id, startInstant: instant)
      }
      return nil
    case .testEnded where false == test?.isSuite:
      let id = test!.id
      let keyPath = id.keyPathRepresentation
      _context.withLock { context in
        context.testData[keyPath]?.endInstant = instant
      }
      return nil
    case let .testSkipped(skipInfo) where false == test?.isSuite:
      let id = test!.id
      let keyPath = id.keyPathRepresentation
      _context.withLock { context in
        context.testData[keyPath] = _Context.TestData(id: id, startInstant: instant, skipInfo: skipInfo)
      }
      return nil
    case let .issueRecorded(issue):
      if issue.isKnown {
        return nil
      }
      if let id = test?.id {
        let keyPath = id.keyPathRepresentation
        _context.withLock { context in
          context.testData[keyPath]?.issues.append(issue)
        }
      } else {
        _context.withLock { context in
          context.issuesForUnknownTests.append(issue)
        }
      }
      return nil
    case .runEnded:
      return _context.withLock { context in
        let issueCount = context.testData
          .compactMap(\.value?.issues.count)
          .reduce(into: 0, +=) + context.issuesForUnknownTests.count
        let skipCount = context.testData
          .compactMap(\.value?.skipInfo)
          .count
        let durationNanoseconds = context.runStartInstant.map { $0.nanoseconds(until: instant) } ?? 0
        let durationSeconds = Double(durationNanoseconds) / 1_000_000_000
        return #"""
            <testsuite name="TestResults" errors="0" tests="\#(context.testCount)" failures="\#(issueCount)" skipped="\#(skipCount)" time="\#(durationSeconds)">
          \#(Self._xml(for: context.testData))
            </testsuite>
          </testsuites>

          """#
      }
    default:
      return nil
    }
  }

  /// Generate XML for a graph of test data.
  ///
  /// - Parameters:
  ///   - testDataGraph: The test data graph.
  ///
  /// - Returns: A string containing (partial) XML for the given test graph.
  ///
  /// This function calls itself recursively as it walks `testDataGraph` in
  /// order to build up the XML output for all nodes therein.
  private static func _xml(for testDataGraph: Graph<String, _Context.TestData?>) -> String {
    var result = [String]()

    if let testData = testDataGraph.value {
      let id = testData.id
      let classNameComponents = CollectionOfOne(id.moduleName) + id.nameComponents.dropLast()
      let className = classNameComponents.joined(separator: ".")
      let name = id.nameComponents.last!
      let durationNanoseconds = testData.startInstant.nanoseconds(until: testData.endInstant ?? .now)
      let durationSeconds = Double(durationNanoseconds) / 1_000_000_000

      // Build out any child nodes contained within this <testcase> node.
      var minutiae = [String]()
      for issue in testData.issues.lazy.map(String.init(describingForTest:)) {
        minutiae.append(#"      <failure message="\#(Self._escapeForXML(issue))" />"#)
      }
      if let skipInfo = testData.skipInfo {
        if let comment = skipInfo.comment.map(String.init(describingForTest:)) {
          minutiae.append(#"      <skipped>\#(Self._escapeForXML(comment))</skipped>"#)
        } else {
          minutiae.append(#"      <skipped />"#)
        }
      }

      if minutiae.isEmpty {
        result.append(#"    <testcase classname="\#(className)" name="\#(name)" time="\#(durationSeconds)" />"#)
      } else {
        result.append(#"    <testcase classname="\#(className)" name="\#(name)" time="\#(durationSeconds)">"#)
        result += minutiae
        result.append(#"    </testcase>"#)
      }
    } else {
      for childGraph in testDataGraph.children.values {
        result.append(_xml(for: childGraph))
      }
    }

    return result.joined(separator: "\n")
  }

  /// Escape a single Unicode character for use in an XML-encoded string.
  ///
  /// - Parameters:
  ///   - character: The character to escape.
  ///
  /// - Returns: `character`, or a string containing its escaped form.
  private static func _escapeForXML(_ character: Character) -> String {
    switch character {
    case #"""#:
      "&quot;"
    case "<":
      "&lt;"
    case ">":
      "&gt;"
    case "&":
      "&amp;"
    case _ where !character.isASCII || character.isNewline:
      character.unicodeScalars.lazy
        .map(\.value)
        .map { "&#\($0);" }
        .joined()
    default:
      String(character)
    }
  }

  /// Escape a string for use in XML.
  ///
  /// - Parameters:
  ///   - string: The string to escape.
  ///
  /// - Returns: A copy of `string` that has been escaped for XML.
  private static func _escapeForXML(_ string: String) -> String {
    string.lazy.map(_escapeForXML).joined()
  }

  /// Record the specified event by generating a representation of it in this
  /// instance's output format and writing it to this instance's destination.
  ///
  /// - Parameters:
  ///   - event: The event to record.
  ///   - context: The context associated with the event.
  ///
  /// - Returns: Whether any output was produced and written to this instance's
  ///   destination.
  @discardableResult public func record(_ event: borrowing Event, in context: borrowing Event.Context) -> Bool {
    if let output = _record(event, in: context) {
      write(output)
      return true
    }
    return false
  }
}