|
| 1 | +#!/usr/bin/swift |
| 2 | + |
| 3 | +import Foundation |
| 4 | + |
| 5 | +let junit = CommandLine.arguments.count > 1 ? URL(filePath: CommandLine.arguments[1]) : nil |
| 6 | +let coverage = CommandLine.arguments.count > 2 ? URL(filePath: CommandLine.arguments[2]) : nil |
| 7 | + |
| 8 | +guard let junit else { |
| 9 | + print("usage: ./failed-tests <junit.xml>") |
| 10 | + exit(70) |
| 11 | +} |
| 12 | + |
| 13 | +let document = try XMLDocument(contentsOf: junit) |
| 14 | +let testCases = try document.nodes(forXPath: "//testcase") |
| 15 | +let failures = try document.nodes(forXPath: "//failure/..") |
| 16 | + |
| 17 | +let table = makeTable( |
| 18 | + total: testCases.count, |
| 19 | + failed: failures.count, |
| 20 | + passed: testCases.count - failures.count |
| 21 | +) |
| 22 | +print(table) |
| 23 | + |
| 24 | +for node in failures { |
| 25 | + guard let element = node as? XMLElement, |
| 26 | + let testClass = element.attribute(forName: "classname")?.stringValue, |
| 27 | + let testName = element.attribute(forName: "name")?.stringValue else { |
| 28 | + continue |
| 29 | + } |
| 30 | + |
| 31 | + print("::warning ::Failed Test: \(testClass).\(testName)()") |
| 32 | + let messages = element |
| 33 | + .elements(forName: "failure") |
| 34 | + .compactMap { $0.attribute(forName: "message")?.stringValue } |
| 35 | + |
| 36 | + print(messages.joined(separator: ". ")) |
| 37 | +} |
| 38 | + |
| 39 | +if let coverage { |
| 40 | + let table = try makeTable( |
| 41 | + name: coverage.lastPathComponent, |
| 42 | + coverage: JSONDecoder().decode(Coverage.self, from: Data(contentsOf: coverage)) |
| 43 | + ) |
| 44 | + print(table) |
| 45 | +} |
| 46 | + |
| 47 | +func makeTable( |
| 48 | + total: Int, |
| 49 | + failed: Int, |
| 50 | + passed: Int, |
| 51 | + skipped: Int = 0 |
| 52 | +) -> String { |
| 53 | + """ |
| 54 | + <table> |
| 55 | + <tr><td></td><td><b>Tests</b></td><td><b>Passed</b> ✅</td><td><b>Skipped</b> ⏭️</td><td><b>Failed</b> ❌</td></tr> |
| 56 | + <tr><td>\(junit.lastPathComponent)</td><td>\(total) ran</td><td>\(passed) passed</td><td>\(skipped) skipped</td><td>\(failed) failed</td></tr> |
| 57 | + </table> |
| 58 | + """ |
| 59 | +} |
| 60 | + |
| 61 | +func makeTable( |
| 62 | + name: String, |
| 63 | + coverage: Coverage |
| 64 | +) -> String { |
| 65 | + """ |
| 66 | + <table> |
| 67 | + <tr><td></td><td><b>Covered</b></td><td><b>Total</b></td><td><b>Coverage</b></td></tr> |
| 68 | + <tr><td>\(name)</td><td>\(coverage.covered)</td><td>\(coverage.count)</td><td>\(coverage.percentString)</td></tr> |
| 69 | + </table> |
| 70 | + """ |
| 71 | +} |
| 72 | + |
| 73 | +struct Coverage: Decodable { |
| 74 | + var count: Int |
| 75 | + var covered: Int |
| 76 | + var percent: Double |
| 77 | + |
| 78 | + init(from decoder: any Decoder) throws { |
| 79 | + var unkeyed = try decoder |
| 80 | + .container(keyedBy: CodingKeys.self) |
| 81 | + .nestedUnkeyedContainer(forKey: .data) |
| 82 | + let container = try unkeyed |
| 83 | + .nestedContainer(keyedBy: CodingKeys.self) |
| 84 | + .nestedContainer(keyedBy: CodingKeys.self, forKey: .totals) |
| 85 | + .nestedContainer(keyedBy: CodingKeys.self, forKey: .lines) |
| 86 | + |
| 87 | + self.count = try container.decode(Int.self, forKey: .count) |
| 88 | + self.covered = try container.decode(Int.self, forKey: .covered) |
| 89 | + self.percent = try container.decode(Double.self, forKey: .percent) |
| 90 | + } |
| 91 | + |
| 92 | + enum CodingKeys: String, CodingKey { |
| 93 | + case data |
| 94 | + case totals |
| 95 | + case lines |
| 96 | + case count |
| 97 | + case covered |
| 98 | + case percent |
| 99 | + } |
| 100 | + |
| 101 | + var percentString: String { |
| 102 | + let formatter = NumberFormatter() |
| 103 | + formatter.numberStyle = .percent |
| 104 | + formatter.maximumFractionDigits = 2 |
| 105 | + return formatter.string(from: (percent / 100) as NSNumber) ?? "asdf" |
| 106 | + } |
| 107 | +} |
0 commit comments