|
| 1 | +/// The original implementation of this file is from Carton. |
| 2 | +/// https://github.com/swiftwasm/carton/blob/1.1.3/Sources/carton-frontend-slim/TestRunners/TestsParser.swift |
| 3 | + |
| 4 | +import Foundation |
| 5 | +import RegexBuilder |
| 6 | + |
| 7 | +protocol InteractiveWriter { |
| 8 | + func write(_ string: String) |
| 9 | +} |
| 10 | + |
| 11 | +protocol TestsParser { |
| 12 | + /// Parse the output of a test process, format it, then output in the `InteractiveWriter`. |
| 13 | + func onLine(_ line: String, _ terminal: InteractiveWriter) |
| 14 | + func finalize(_ terminal: InteractiveWriter) |
| 15 | +} |
| 16 | + |
| 17 | +extension String.StringInterpolation { |
| 18 | + /// Display `value` with the specified ANSI-escaped `color` values, then apply the reset. |
| 19 | + fileprivate mutating func appendInterpolation<T>(_ value: T, color: String...) { |
| 20 | + appendInterpolation("\(color.map { "\u{001B}\($0)" }.joined())\(value)\u{001B}[0m") |
| 21 | + } |
| 22 | +} |
| 23 | + |
| 24 | +class FancyTestsParser: TestsParser { |
| 25 | + init() {} |
| 26 | + |
| 27 | + enum Status: Equatable { |
| 28 | + case passed, failed, skipped |
| 29 | + case unknown(String.SubSequence?) |
| 30 | + |
| 31 | + var isNegative: Bool { |
| 32 | + switch self { |
| 33 | + case .failed, .unknown(nil): return true |
| 34 | + default: return false |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + init(rawValue: String.SubSequence) { |
| 39 | + switch rawValue { |
| 40 | + case "passed": self = .passed |
| 41 | + case "failed": self = .failed |
| 42 | + case "skipped": self = .skipped |
| 43 | + default: self = .unknown(rawValue) |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + struct Suite { |
| 49 | + let name: String.SubSequence |
| 50 | + var status: Status = .unknown(nil) |
| 51 | + |
| 52 | + var statusLabel: String { |
| 53 | + switch status { |
| 54 | + case .passed: return "\(" PASSED ", color: "[1m", "[97m", "[42m")" |
| 55 | + case .failed: return "\(" FAILED ", color: "[1m", "[97m", "[101m")" |
| 56 | + case .skipped: return "\(" SKIPPED ", color: "[1m", "[97m", "[97m")" |
| 57 | + case .unknown(let status): |
| 58 | + return "\(" \(status ?? "UNKNOWN") ", color: "[1m", "[97m", "[101m")" |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + var cases: [Case] |
| 63 | + |
| 64 | + struct Case { |
| 65 | + let name: String.SubSequence |
| 66 | + var statusMark: String { |
| 67 | + switch status { |
| 68 | + case .passed: return "\("\u{2714}", color: "[92m")" |
| 69 | + case .failed: return "\("\u{2718}", color: "[91m")" |
| 70 | + case .skipped: return "\("\u{279C}", color: "[97m")" |
| 71 | + case .unknown: return "\("?", color: "[97m")" |
| 72 | + } |
| 73 | + } |
| 74 | + var status: Status = .unknown(nil) |
| 75 | + var duration: String.SubSequence? |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + var suites = [Suite]() |
| 80 | + |
| 81 | + let swiftIdentifier = #/[_\p{L}\p{Nl}][_\p{L}\p{Nl}\p{Mn}\p{Nd}\p{Pc}]*/# |
| 82 | + let timestamp = #/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}/# |
| 83 | + lazy var suiteStarted = Regex { |
| 84 | + "Test Suite '" |
| 85 | + Capture { |
| 86 | + OneOrMore(CharacterClass.anyOf("'").inverted) |
| 87 | + } |
| 88 | + "' started at " |
| 89 | + Capture { self.timestamp } |
| 90 | + } |
| 91 | + lazy var suiteStatus = Regex { |
| 92 | + "Test Suite '" |
| 93 | + Capture { OneOrMore(CharacterClass.anyOf("'").inverted) } |
| 94 | + "' " |
| 95 | + Capture { |
| 96 | + ChoiceOf { |
| 97 | + "failed" |
| 98 | + "passed" |
| 99 | + } |
| 100 | + } |
| 101 | + " at " |
| 102 | + Capture { self.timestamp } |
| 103 | + } |
| 104 | + lazy var testCaseStarted = Regex { |
| 105 | + "Test Case '" |
| 106 | + Capture { self.swiftIdentifier } |
| 107 | + "." |
| 108 | + Capture { self.swiftIdentifier } |
| 109 | + "' started" |
| 110 | + } |
| 111 | + lazy var testCaseStatus = Regex { |
| 112 | + "Test Case '" |
| 113 | + Capture { self.swiftIdentifier } |
| 114 | + "." |
| 115 | + Capture { self.swiftIdentifier } |
| 116 | + "' " |
| 117 | + Capture { |
| 118 | + ChoiceOf { |
| 119 | + "failed" |
| 120 | + "passed" |
| 121 | + "skipped" |
| 122 | + } |
| 123 | + } |
| 124 | + " (" |
| 125 | + Capture { |
| 126 | + OneOrMore(.digit) |
| 127 | + "." |
| 128 | + OneOrMore(.digit) |
| 129 | + } |
| 130 | + " seconds)" |
| 131 | + } |
| 132 | + |
| 133 | + let testSummary = |
| 134 | + #/Executed \d+ (test|tests), with (?:\d+ (?:test|tests) skipped and )?\d+ (failure|failures) \((?<unexpected>\d+) unexpected\) in (?<duration>\d+\.\d+) \(\d+\.\d+\) seconds/# |
| 135 | + |
| 136 | + func onLine(_ line: String, _ terminal: InteractiveWriter) { |
| 137 | + if let match = line.firstMatch( |
| 138 | + of: suiteStarted |
| 139 | + ) { |
| 140 | + let (_, suite, _) = match.output |
| 141 | + suites.append(.init(name: suite, cases: [])) |
| 142 | + } else if let match = line.firstMatch( |
| 143 | + of: suiteStatus |
| 144 | + ) { |
| 145 | + let (_, suite, status, _) = match.output |
| 146 | + if let suiteIdx = suites.firstIndex(where: { $0.name == suite }) { |
| 147 | + suites[suiteIdx].status = Status(rawValue: status) |
| 148 | + flushSingleSuite(suites[suiteIdx], terminal) |
| 149 | + } |
| 150 | + } else if let match = line.firstMatch( |
| 151 | + of: testCaseStarted |
| 152 | + ) { |
| 153 | + let (_, suite, testCase) = match.output |
| 154 | + if let suiteIdx = suites.firstIndex(where: { $0.name == suite }) { |
| 155 | + suites[suiteIdx].cases.append( |
| 156 | + .init(name: testCase, duration: nil) |
| 157 | + ) |
| 158 | + } |
| 159 | + } else if let match = line.firstMatch( |
| 160 | + of: testCaseStatus |
| 161 | + ) { |
| 162 | + let (_, suite, testCase, status, duration) = match.output |
| 163 | + if let suiteIdx = suites.firstIndex(where: { $0.name == suite }) { |
| 164 | + if let caseIdx = suites[suiteIdx].cases.firstIndex(where: { |
| 165 | + $0.name == testCase |
| 166 | + }) { |
| 167 | + suites[suiteIdx].cases[caseIdx].status = Status(rawValue: status) |
| 168 | + suites[suiteIdx].cases[caseIdx].duration = duration |
| 169 | + } |
| 170 | + } |
| 171 | + } else if line.firstMatch(of: testSummary) != nil { |
| 172 | + // do nothing |
| 173 | + } else { |
| 174 | + if !line.isEmpty { |
| 175 | + terminal.write(line + "\n") |
| 176 | + } |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + func finalize(_ terminal: InteractiveWriter) { |
| 181 | + terminal.write("\n") |
| 182 | + flushSummary(of: suites, terminal) |
| 183 | + } |
| 184 | + |
| 185 | + private func flushSingleSuite(_ suite: Suite, _ terminal: InteractiveWriter) { |
| 186 | + terminal.write(suite.statusLabel) |
| 187 | + terminal.write(" \(suite.name)\n") |
| 188 | + for testCase in suite.cases { |
| 189 | + terminal.write(" \(testCase.statusMark) ") |
| 190 | + if let duration = testCase.duration { |
| 191 | + terminal |
| 192 | + .write( |
| 193 | + "\(testCase.name) \("(\(Int(Double(duration)! * 1000))ms)", color: "[90m")\n" |
| 194 | + ) // gray |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + private func flushSummary(of suites: [Suite], _ terminal: InteractiveWriter) { |
| 200 | + let suitesWithCases = suites.filter { $0.cases.count > 0 } |
| 201 | + |
| 202 | + terminal.write("Test Suites: ") |
| 203 | + let suitesPassed = suitesWithCases.filter { $0.status == .passed }.count |
| 204 | + if suitesPassed > 0 { |
| 205 | + terminal.write("\("\(suitesPassed) passed", color: "[32m"), ") |
| 206 | + } |
| 207 | + let suitesSkipped = suitesWithCases.filter { $0.status == .skipped }.count |
| 208 | + if suitesSkipped > 0 { |
| 209 | + terminal.write("\("\(suitesSkipped) skipped", color: "[97m"), ") |
| 210 | + } |
| 211 | + let suitesFailed = suitesWithCases.filter { $0.status == .failed }.count |
| 212 | + if suitesFailed > 0 { |
| 213 | + terminal.write("\("\(suitesFailed) failed", color: "[31m"), ") |
| 214 | + } |
| 215 | + let suitesUnknown = suitesWithCases.filter { $0.status == .unknown(nil) }.count |
| 216 | + if suitesUnknown > 0 { |
| 217 | + terminal.write("\("\(suitesUnknown) unknown", color: "[31m"), ") |
| 218 | + } |
| 219 | + terminal.write("\(suitesWithCases.count) total\n") |
| 220 | + |
| 221 | + terminal.write("Tests: ") |
| 222 | + let allTests = suitesWithCases.map(\.cases).reduce([], +) |
| 223 | + let testsPassed = allTests.filter { $0.status == .passed }.count |
| 224 | + if testsPassed > 0 { |
| 225 | + terminal.write("\("\(testsPassed) passed", color: "[32m"), ") |
| 226 | + } |
| 227 | + let testsSkipped = allTests.filter { $0.status == .skipped }.count |
| 228 | + if testsSkipped > 0 { |
| 229 | + terminal.write("\("\(testsSkipped) skipped", color: "[97m"), ") |
| 230 | + } |
| 231 | + let testsFailed = allTests.filter { $0.status == .failed }.count |
| 232 | + if testsFailed > 0 { |
| 233 | + terminal.write("\("\(testsFailed) failed", color: "[31m"), ") |
| 234 | + } |
| 235 | + let testsUnknown = allTests.filter { $0.status == .unknown(nil) }.count |
| 236 | + if testsUnknown > 0 { |
| 237 | + terminal.write("\("\(testsUnknown) unknown", color: "[31m"), ") |
| 238 | + } |
| 239 | + terminal.write("\(allTests.count) total\n") |
| 240 | + |
| 241 | + if suites.contains(where: { $0.name == "All tests" }) { |
| 242 | + terminal.write("\("Ran all test suites.", color: "[90m")\n") // gray |
| 243 | + } |
| 244 | + |
| 245 | + if suites.contains(where: { $0.status.isNegative }) { |
| 246 | + print(suites.filter({ $0.status.isNegative })) |
| 247 | + terminal.write("\n\("Failed test cases:", color: "[31m")\n") |
| 248 | + for suite in suites.filter({ $0.status.isNegative }) { |
| 249 | + for testCase in suite.cases.filter({ $0.status.isNegative }) { |
| 250 | + terminal.write(" \(testCase.statusMark) \(suite.name).\(testCase.name)\n") |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + terminal.write( |
| 255 | + "\n\("Some tests failed. Use --verbose for raw test output.", color: "[33m")\n" |
| 256 | + ) |
| 257 | + } |
| 258 | + } |
| 259 | +} |
0 commit comments