|
| 1 | +// |
| 2 | +// This source file is part of the Swift.org open source project |
| 3 | +// |
| 4 | +// Copyright (c) 2023 Apple Inc. and the Swift project authors |
| 5 | +// Licensed under Apache License v2.0 with Runtime Library Exception |
| 6 | +// |
| 7 | +// See https://swift.org/LICENSE.txt for license information |
| 8 | +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors |
| 9 | +// |
| 10 | + |
| 11 | +extension Event { |
| 12 | + /// A type which handles ``Event`` instances and outputs representations of |
| 13 | + /// them as human-readable messages. |
| 14 | + /// |
| 15 | + /// This type can be used compositionally to produce output in other |
| 16 | + /// human-readable formats such as rich text or HTML. |
| 17 | + /// |
| 18 | + /// The format of the output is not meant to be machine-readable and is |
| 19 | + /// subject to change. For machine-readable output, use ``JUnitXMLRecorder``. |
| 20 | + @_spi(ExperimentalEventRecording) |
| 21 | + public struct HumanReadableOutputRecorder: Sendable { |
| 22 | + /// A type describing a human-readable message produced by an instance of |
| 23 | + /// ``Event/HumanReadableOutputRecorder``. |
| 24 | + public struct Message: Sendable { |
| 25 | + /// The symbol associated with this message, if any. |
| 26 | + var symbol: Symbol? |
| 27 | + |
| 28 | + /// The human-readable message. |
| 29 | + var stringValue: String |
| 30 | + } |
| 31 | + |
| 32 | + /// A type that contains mutable context for |
| 33 | + /// ``Event/ConsoleOutputRecorder``. |
| 34 | + private struct _Context { |
| 35 | + /// The instant at which the run started. |
| 36 | + var runStartInstant: Test.Clock.Instant? |
| 37 | + |
| 38 | + /// The number of tests started or skipped during the run. |
| 39 | + /// |
| 40 | + /// This value does not include test suites. |
| 41 | + var testCount = 0 |
| 42 | + |
| 43 | + /// The number of test suites started or skipped during the run. |
| 44 | + var suiteCount = 0 |
| 45 | + |
| 46 | + /// A type describing data tracked on a per-test basis. |
| 47 | + struct TestData { |
| 48 | + /// The instant at which the test started. |
| 49 | + var startInstant: Test.Clock.Instant |
| 50 | + |
| 51 | + /// The number of issues recorded for the test. |
| 52 | + var issueCount = 0 |
| 53 | + |
| 54 | + /// The number of known issues recorded for the test. |
| 55 | + var knownIssueCount = 0 |
| 56 | + } |
| 57 | + |
| 58 | + /// Data tracked on a per-test basis. |
| 59 | + var testData = Graph<String, TestData?>() |
| 60 | + } |
| 61 | + |
| 62 | + /// This event recorder's mutable context about events it has received, |
| 63 | + /// which may be used to inform how subsequent events are written. |
| 64 | + @Locked private var _context = _Context() |
| 65 | + |
| 66 | + /// Initialize a new human-readable event recorder. |
| 67 | + /// |
| 68 | + /// Output from the testing library is converted to "messages" using the |
| 69 | + /// ``Event/HumanReadableOutputRecorder/record(_:)`` function. The format of |
| 70 | + /// those messages is, as the type's name suggests, not meant to be |
| 71 | + /// machine-readable and is subject to change. |
| 72 | + public init() {} |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +// MARK: - |
| 77 | + |
| 78 | +extension Event.HumanReadableOutputRecorder { |
| 79 | + /// Get a string representing an array of comments, formatted for output. |
| 80 | + /// |
| 81 | + /// - Parameters: |
| 82 | + /// - comments: The comments that should be formatted. |
| 83 | + /// |
| 84 | + /// - Returns: A formatted string representing `comments`, or `nil` if there |
| 85 | + /// are none. |
| 86 | + private func _formattedComments(_ comments: [Comment]) -> [Message] { |
| 87 | + // Insert an arrow character at the start of each comment, then indent any |
| 88 | + // additional lines in the comment to align them with the arrow. |
| 89 | + comments.lazy |
| 90 | + .flatMap { comment in |
| 91 | + let lines = comment.rawValue.split(whereSeparator: \.isNewline) |
| 92 | + if let firstLine = lines.first { |
| 93 | + let remainingLines = lines.dropFirst() |
| 94 | + return CollectionOfOne(Message(symbol: .details, stringValue: String(firstLine))) + remainingLines.lazy |
| 95 | + .map(String.init) |
| 96 | + .map { Message(stringValue: $0) } |
| 97 | + } |
| 98 | + return [] |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + /// Get a string representing the comments attached to a test, formatted for |
| 103 | + /// output. |
| 104 | + /// |
| 105 | + /// - Parameters: |
| 106 | + /// - test: The test whose comments should be formatted. |
| 107 | + /// |
| 108 | + /// - Returns: A formatted string representing the comments attached to `test`, |
| 109 | + /// or `nil` if there are none. |
| 110 | + private func _formattedComments(for test: Test) -> [Message] { |
| 111 | + _formattedComments(test.comments(from: Comment.self)) |
| 112 | + } |
| 113 | + |
| 114 | + /// Get the total number of issues recorded in a graph of test data |
| 115 | + /// structures. |
| 116 | + /// |
| 117 | + /// - Parameters: |
| 118 | + /// - graph: The graph to walk while counting issues. |
| 119 | + /// |
| 120 | + /// - Returns: A tuple containing the number of issues recorded in `graph`. |
| 121 | + private func _issueCounts(in graph: Graph<String, Event.HumanReadableOutputRecorder._Context.TestData?>?) -> (issueCount: Int, knownIssueCount: Int, totalIssueCount: Int, description: String) { |
| 122 | + guard let graph else { |
| 123 | + return (0, 0, 0, "") |
| 124 | + } |
| 125 | + let issueCount = graph.compactMap(\.value?.issueCount).reduce(into: 0, +=) |
| 126 | + let knownIssueCount = graph.compactMap(\.value?.knownIssueCount).reduce(into: 0, +=) |
| 127 | + let totalIssueCount = issueCount + knownIssueCount |
| 128 | + |
| 129 | + // Construct a string describing the issue counts. |
| 130 | + let description = switch (issueCount > 0, knownIssueCount > 0) { |
| 131 | + case (true, true): |
| 132 | + " with \(totalIssueCount.counting("issue")) (including \(knownIssueCount.counting("known issue")))" |
| 133 | + case (false, true): |
| 134 | + " with \(knownIssueCount.counting("known issue"))" |
| 135 | + case (true, false): |
| 136 | + " with \(totalIssueCount.counting("issue"))" |
| 137 | + case(false, false): |
| 138 | + "" |
| 139 | + } |
| 140 | + |
| 141 | + return (issueCount, knownIssueCount, totalIssueCount, description) |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +extension Test.Case { |
| 146 | + /// The arguments of this test case, formatted for presentation, prefixed by |
| 147 | + /// their corresponding parameter label when available. |
| 148 | + fileprivate var labeledArguments: String { |
| 149 | + arguments.lazy |
| 150 | + .map { argument in |
| 151 | + let valueDescription = String(describingForTest: argument.value) |
| 152 | + |
| 153 | + let label = argument.parameter.secondName ?? argument.parameter.firstName |
| 154 | + guard label != "_" else { |
| 155 | + return valueDescription |
| 156 | + } |
| 157 | + return "\(label) → \(valueDescription)" |
| 158 | + } |
| 159 | + .joined(separator: ", ") |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +// MARK: - |
| 164 | + |
| 165 | +extension Event.HumanReadableOutputRecorder { |
| 166 | + /// Record the specified event by generating zero or more messages that |
| 167 | + /// describe it. |
| 168 | + /// |
| 169 | + /// - Parameters: |
| 170 | + /// - event: The event to record. |
| 171 | + /// - eventContext: The context associated with the event. |
| 172 | + /// |
| 173 | + /// - Returns: An array of zero or more messages that can be displayed to the |
| 174 | + /// user. |
| 175 | + @discardableResult public func record(_ event: borrowing Event, in eventContext: borrowing Event.Context) -> [Message] { |
| 176 | + let test = eventContext.test |
| 177 | + var testName: String |
| 178 | + if let displayName = test?.displayName { |
| 179 | + testName = "\"\(displayName)\"" |
| 180 | + } else if let test { |
| 181 | + testName = test.name |
| 182 | + } else { |
| 183 | + testName = "«unknown»" |
| 184 | + } |
| 185 | + let instant = event.instant |
| 186 | + |
| 187 | + switch event.kind { |
| 188 | + case .runStarted: |
| 189 | + $_context.withLock { context in |
| 190 | + context.runStartInstant = instant |
| 191 | + } |
| 192 | + var comments: [Comment] = [ |
| 193 | + "Swift Version: \(swiftStandardLibraryVersion)", |
| 194 | + "Testing Library Version: \(testingLibraryVersion)", |
| 195 | + ] |
| 196 | +#if targetEnvironment(simulator) |
| 197 | + comments.append("OS Version (Simulator): \(simulatorVersion)") |
| 198 | + comments.append("OS Version (Host): \(operatingSystemVersion)") |
| 199 | +#else |
| 200 | + comments.append("OS Version: \(operatingSystemVersion)") |
| 201 | +#endif |
| 202 | + return CollectionOfOne( |
| 203 | + Message( |
| 204 | + symbol: .default, |
| 205 | + stringValue: "Test run started." |
| 206 | + ) |
| 207 | + ) + _formattedComments(comments) |
| 208 | + |
| 209 | + case .planStepStarted, .planStepEnded: |
| 210 | + // Suppress events of these kinds from output as they are not generally |
| 211 | + // interesting in human-readable output. |
| 212 | + break |
| 213 | + |
| 214 | + case .testStarted: |
| 215 | + let test = test! |
| 216 | + $_context.withLock { context in |
| 217 | + context.testData[test.id.keyPathRepresentation] = .init(startInstant: instant) |
| 218 | + if test.isSuite { |
| 219 | + context.suiteCount += 1 |
| 220 | + } else { |
| 221 | + context.testCount += 1 |
| 222 | + } |
| 223 | + } |
| 224 | + return [ |
| 225 | + Message( |
| 226 | + symbol: .default, |
| 227 | + stringValue: "Test \(testName) started." |
| 228 | + ) |
| 229 | + ] |
| 230 | + |
| 231 | + case .testEnded: |
| 232 | + let test = test! |
| 233 | + let id = test.id |
| 234 | + let testDataGraph = _context.testData.subgraph(at: id.keyPathRepresentation) |
| 235 | + let testData = testDataGraph?.value ?? .init(startInstant: instant) |
| 236 | + let issues = _issueCounts(in: testDataGraph) |
| 237 | + let duration = testData.startInstant.descriptionOfDuration(to: instant) |
| 238 | + return if issues.issueCount > 0 { |
| 239 | + CollectionOfOne( |
| 240 | + Message( |
| 241 | + symbol: .fail, |
| 242 | + stringValue: "Test \(testName) failed after \(duration)\(issues.description)." |
| 243 | + ) |
| 244 | + ) + _formattedComments(for: test) |
| 245 | + } else { |
| 246 | + [ |
| 247 | + Message( |
| 248 | + symbol: .pass(knownIssueCount: issues.knownIssueCount), |
| 249 | + stringValue: "Test \(testName) passed after \(duration)\(issues.description)." |
| 250 | + ) |
| 251 | + ] |
| 252 | + } |
| 253 | + |
| 254 | + case let .testSkipped(skipInfo): |
| 255 | + let test = test! |
| 256 | + $_context.withLock { context in |
| 257 | + if test.isSuite { |
| 258 | + context.suiteCount += 1 |
| 259 | + } else { |
| 260 | + context.testCount += 1 |
| 261 | + } |
| 262 | + } |
| 263 | + return if let comment = skipInfo.comment { |
| 264 | + [ |
| 265 | + Message(symbol: .skip, stringValue: "Test \(testName) skipped: \"\(comment.rawValue)\"") |
| 266 | + ] |
| 267 | + } else { |
| 268 | + [ |
| 269 | + Message(symbol: .skip, stringValue: "Test \(testName) skipped.") |
| 270 | + ] |
| 271 | + } |
| 272 | + |
| 273 | + case .expectationChecked: |
| 274 | + // Suppress events of this kind from output as they are not generally |
| 275 | + // interesting in human-readable output. |
| 276 | + break |
| 277 | + |
| 278 | + case let .issueRecorded(issue): |
| 279 | + if let test { |
| 280 | + let id = test.id.keyPathRepresentation |
| 281 | + $_context.withLock { context in |
| 282 | + var testData = context.testData[id] ?? .init(startInstant: instant) |
| 283 | + if issue.isKnown { |
| 284 | + testData.knownIssueCount += 1 |
| 285 | + } else { |
| 286 | + testData.issueCount += 1 |
| 287 | + } |
| 288 | + context.testData[id] = testData |
| 289 | + } |
| 290 | + } |
| 291 | + let parameterCount = if let parameters = test?.parameters { |
| 292 | + parameters.count |
| 293 | + } else { |
| 294 | + 0 |
| 295 | + } |
| 296 | + let labeledArguments = if let testCase = eventContext.testCase { |
| 297 | + testCase.labeledArguments |
| 298 | + } else { |
| 299 | + "" |
| 300 | + } |
| 301 | + let symbol: Event.Symbol |
| 302 | + let known: String |
| 303 | + if issue.isKnown { |
| 304 | + symbol = .pass(knownIssueCount: 1) |
| 305 | + known = " known" |
| 306 | + } else { |
| 307 | + symbol = .fail |
| 308 | + known = "n" |
| 309 | + } |
| 310 | + |
| 311 | + var additionalMessages = [Message]() |
| 312 | + if case let .expectationFailed(expectation) = issue.kind, let differenceDescription = expectation.differenceDescription { |
| 313 | + additionalMessages.append(Message(symbol: .difference, stringValue: differenceDescription)) |
| 314 | + } |
| 315 | + additionalMessages += _formattedComments(issue.comments) |
| 316 | + |
| 317 | + let atSourceLocation = issue.sourceLocation.map { " at \($0)" } ?? "" |
| 318 | + let primaryMessage: Message = if parameterCount == 0 { |
| 319 | + Message( |
| 320 | + symbol: symbol, |
| 321 | + stringValue: "Test \(testName) recorded a\(known) issue\(atSourceLocation): \(issue.kind)" |
| 322 | + ) |
| 323 | + } else { |
| 324 | + Message( |
| 325 | + symbol: symbol, |
| 326 | + stringValue: "Test \(testName) recorded a\(known) issue with \(parameterCount.counting("argument")) \(labeledArguments)\(atSourceLocation): \(issue.kind)" |
| 327 | + ) |
| 328 | + } |
| 329 | + return CollectionOfOne(primaryMessage) + additionalMessages |
| 330 | + |
| 331 | + case .testCaseStarted: |
| 332 | + guard let testCase = eventContext.testCase, testCase.isParameterized else { |
| 333 | + break |
| 334 | + } |
| 335 | + |
| 336 | + return [ |
| 337 | + Message( |
| 338 | + symbol: .default, |
| 339 | + stringValue: "Passing \(testCase.arguments.count.counting("argument")) \(testCase.labeledArguments) to \(testName)" |
| 340 | + ) |
| 341 | + ] |
| 342 | + |
| 343 | + case .testCaseEnded: |
| 344 | + break |
| 345 | + |
| 346 | + case .runEnded: |
| 347 | + let context = $_context.wrappedValue |
| 348 | + |
| 349 | + let testCount = context.testCount |
| 350 | + let issues = _issueCounts(in: context.testData) |
| 351 | + let runStartInstant = context.runStartInstant ?? instant |
| 352 | + let duration = runStartInstant.descriptionOfDuration(to: instant) |
| 353 | + |
| 354 | + return if issues.issueCount > 0 { |
| 355 | + [ |
| 356 | + Message( |
| 357 | + symbol: .fail, |
| 358 | + stringValue: "Test run with \(testCount.counting("test")) failed after \(duration)\(issues.description)." |
| 359 | + ) |
| 360 | + ] |
| 361 | + } else { |
| 362 | + [ |
| 363 | + Message( |
| 364 | + symbol: .pass(knownIssueCount: issues.knownIssueCount), |
| 365 | + stringValue: "Test run with \(testCount.counting("test")) passed after \(duration)\(issues.description)." |
| 366 | + ) |
| 367 | + ] |
| 368 | + } |
| 369 | + } |
| 370 | + |
| 371 | + return [] |
| 372 | + } |
| 373 | +} |
| 374 | + |
| 375 | +// MARK: - Codable |
| 376 | + |
| 377 | +extension Event.HumanReadableOutputRecorder.Message: Codable {} |
0 commit comments