329 lines
12 KiB
Swift
329 lines
12 KiB
Swift
// Copyright 2020 Carton contributors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
import Foundation
|
|
import Splash
|
|
import TSCBasic
|
|
|
|
private extension String.StringInterpolation {
|
|
mutating func appendInterpolation(_ regexLabel: TestsParser.Regex.Label) {
|
|
appendInterpolation("<\(regexLabel.rawValue)>")
|
|
}
|
|
}
|
|
|
|
private extension StringProtocol {
|
|
func range(of regex: NSRegularExpression,
|
|
labelled label: TestsParser.Regex.Label) -> Range<String.Index>?
|
|
{
|
|
range(of: regex, named: label.rawValue)
|
|
}
|
|
|
|
func match(of regex: NSRegularExpression, labelled label: TestsParser.Regex.Label) -> String
|
|
.SubSequence?
|
|
{
|
|
match(of: regex, named: label.rawValue)
|
|
}
|
|
|
|
func match(
|
|
of regex: NSRegularExpression,
|
|
labelled labelA: TestsParser.Regex.Label,
|
|
_ labelB: TestsParser.Regex.Label
|
|
) -> (String.SubSequence, String.SubSequence)? {
|
|
guard let a = match(of: regex, named: labelA.rawValue),
|
|
let b = match(of: regex, named: labelB.rawValue)
|
|
else {
|
|
return nil
|
|
}
|
|
return (a, b)
|
|
}
|
|
}
|
|
|
|
public struct TestsParser: ProcessOutputParser {
|
|
public init() {}
|
|
|
|
public let parsingConditions: ParsingCondition = [.success, .failure]
|
|
|
|
// swiftlint:disable force_try
|
|
// swiftlint:disable line_length
|
|
enum Regex {
|
|
enum Label: String {
|
|
case suite
|
|
case testCase
|
|
case status
|
|
case timestamp
|
|
|
|
case testCount
|
|
case failCount
|
|
case unexpectedCount
|
|
case duration
|
|
|
|
case path
|
|
case line
|
|
|
|
case received
|
|
case expected
|
|
}
|
|
|
|
static let suiteStarted = try! NSRegularExpression(
|
|
pattern: #"Test Suite '(?\#(.suite)[^']*)' started at (?\#(.timestamp)\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})"#
|
|
)
|
|
static let suiteFinished = try! NSRegularExpression(
|
|
pattern: #"Test Suite '(?\#(.suite)[^']*)' (?\#(.status)(failed|passed)) at (?\#(.timestamp)\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})"#
|
|
)
|
|
static let suiteSummary = try! NSRegularExpression(
|
|
pattern: #"Executed (?\#(.testCount)\d+) (test|tests), with (?\#(.failCount)\d+) (failure|failures) \((?\#(.unexpectedCount)\d+) unexpected\) in (?\#(.duration)\d+\.\d+) \(\d+\.\d+\) seconds"#
|
|
)
|
|
static let caseFinished = try! NSRegularExpression(
|
|
pattern: #"Test Case '(?\#(.suite)[^']+)\.(?\#(.testCase)[^']+)' (?\#(.status)(failed|passed)) \((?\#(.duration)(\d+)\.(\d+)) seconds\)"#
|
|
)
|
|
static let problem = try! NSRegularExpression(
|
|
pattern: #"(?\#(.path)(.+)(\/|)([^/]+)):(?\#(.line)\d+): (?\#(.status)\w+): (?\#(.suite)\w+)\.(?\#(.testCase)\w+) : "#
|
|
)
|
|
|
|
enum Assertion: String, CaseIterable {
|
|
case equal = "Equal",
|
|
greaterThan = "GreaterThan",
|
|
lessThan = "LessThan",
|
|
greaterThanOrEqual = "GreaterThanOrEqual",
|
|
lessThanOrEqual = "LessThanOrEqual"
|
|
|
|
var funcName: String {
|
|
"XCTAssert\(rawValue)"
|
|
}
|
|
|
|
var innerMessage: String {
|
|
switch self {
|
|
case .equal: return "is not equal to"
|
|
case .greaterThan: return "is not greater than"
|
|
case .lessThan: return "is not less than"
|
|
case .greaterThanOrEqual: return "is less than"
|
|
case .lessThanOrEqual: return "is greater than"
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .equal: return ""
|
|
case .greaterThan: return ">"
|
|
case .lessThan: return "<"
|
|
case .greaterThanOrEqual: return ">="
|
|
case .lessThanOrEqual: return "<="
|
|
}
|
|
}
|
|
}
|
|
|
|
static let xctAssertEqual = try! NSRegularExpression(
|
|
pattern: #"XCTAssertEqual failed: (?\#(.received).*) is not equal to (?\#(.expected).*) - "#
|
|
)
|
|
static let xctAssertGreaterThan = try! NSRegularExpression(
|
|
pattern: #"XCTAssertGreaterThan failed: (?\#(.received).*) is not greater than (?\#(.expected).*) - "#
|
|
)
|
|
static let xctAssertLessThan = try! NSRegularExpression(
|
|
pattern: #"XCTAssertLessThan failed: (?\#(.received).*) is not less than (?\#(.expected).*) - "#
|
|
)
|
|
|
|
static func xctAssert(_ assertion: Assertion) -> NSRegularExpression {
|
|
try! NSRegularExpression(
|
|
pattern: #"\#(assertion.funcName) failed: \("(?\#(.received).*)"\) \#(assertion.innerMessage) \("(?\#(.expected).*)"\) - "#
|
|
)
|
|
}
|
|
}
|
|
|
|
// swiftlint:enable line_length
|
|
// swiftlint:enable force_try
|
|
|
|
struct Suite {
|
|
let name: String.SubSequence
|
|
var passed: Bool {
|
|
fails == 0
|
|
}
|
|
|
|
var cases: [Case]
|
|
var fails: Int {
|
|
cases.filter { !$0.passed }.count
|
|
}
|
|
|
|
struct Case {
|
|
let name: String.SubSequence
|
|
let passed: Bool
|
|
let duration: String.SubSequence
|
|
var problems: [DiagnosticsParser.CustomDiagnostic]
|
|
}
|
|
}
|
|
|
|
fileprivate static let highlighter = SyntaxHighlighter(format: TerminalOutputFormat())
|
|
|
|
public func parse(_ output: String, _ terminal: InteractiveWriter) {
|
|
let lines = output.split(separator: "\n")
|
|
|
|
var suites = [Suite]()
|
|
var unmappedProblems = [(
|
|
suite: String.SubSequence,
|
|
testCase: String.SubSequence,
|
|
problem: DiagnosticsParser.CustomDiagnostic
|
|
)]()
|
|
|
|
for line in lines {
|
|
if let suite = line.match(of: Regex.suiteStarted, labelled: .suite) {
|
|
suites.append(.init(name: suite, cases: []))
|
|
} else if let testCase = line.match(of: Regex.caseFinished, labelled: .testCase),
|
|
let suite = line.match(of: Regex.caseFinished, labelled: .suite),
|
|
let suiteIdx = suites.firstIndex(where: { $0.name == suite }),
|
|
let status = line.match(of: Regex.caseFinished, labelled: .status),
|
|
let duration = line.match(of: Regex.caseFinished, labelled: .duration)
|
|
{
|
|
suites[suiteIdx].cases.append(
|
|
.init(name: testCase, passed: status == "passed", duration: duration, problems: [])
|
|
)
|
|
} else if let problem = line.matches(regex: Regex.problem),
|
|
let path = line.match(of: Regex.problem, labelled: .path),
|
|
let lineNum = line.match(of: Regex.problem, labelled: .line),
|
|
let status = line.match(of: Regex.problem, labelled: .status),
|
|
let suite = line.match(of: Regex.problem, labelled: .suite),
|
|
let testCase = line.match(of: Regex.problem, labelled: .testCase)
|
|
{
|
|
let diag = DiagnosticsParser.CustomDiagnostic(
|
|
kind: DiagnosticsParser.CustomDiagnostic.Kind(rawValue: String(status)) ?? .note,
|
|
file: String(path),
|
|
line: lineNum,
|
|
char: "0",
|
|
code: "",
|
|
message: String(problem)
|
|
)
|
|
if let suiteIdx = suites.firstIndex(where: { $0.name == suite }),
|
|
let caseIdx = suites[suiteIdx].cases.firstIndex(where: { $0.name == testCase })
|
|
{
|
|
suites[suiteIdx].cases[caseIdx].problems.append(diag)
|
|
} else {
|
|
unmappedProblems.append((suite, testCase, diag))
|
|
}
|
|
}
|
|
}
|
|
for problem in unmappedProblems {
|
|
if let suiteIdx = suites.firstIndex(where: { $0.name == problem.suite }),
|
|
let caseIdx = suites[suiteIdx].cases.firstIndex(where: { $0.name == problem.testCase })
|
|
{
|
|
suites[suiteIdx].cases[caseIdx].problems.append(problem.problem)
|
|
}
|
|
}
|
|
|
|
flushSuites(suites, terminal)
|
|
terminal.write("\n")
|
|
flushSummary(of: suites, terminal)
|
|
}
|
|
|
|
func flushSuites(_ suites: [Suite], _ terminal: InteractiveWriter) {
|
|
let suitesWithCases = suites.filter { $0.cases.count > 0 }
|
|
|
|
// Keep track of files we already opened and store their contents
|
|
struct FileBuf: Hashable {
|
|
let path: String
|
|
let contents: String
|
|
}
|
|
var fileBufs = Set<FileBuf>()
|
|
|
|
for suite in suitesWithCases {
|
|
// bold, white fg, green/red bg
|
|
terminal
|
|
.write(
|
|
"""
|
|
\n\(" \(suite.passed ? "PASSED" : "FAILED") ",
|
|
color: "[1m", "[97m", suite.passed ? "[42m" : "[101m"
|
|
)
|
|
"""
|
|
)
|
|
terminal.write(" \(suite.name)\n")
|
|
for testCase in suite.cases {
|
|
if testCase.passed {
|
|
terminal.write(" \("✓", color: "[92m") ") // green
|
|
} else {
|
|
terminal.write(" \("✕", color: "[91m") ") // red
|
|
}
|
|
terminal
|
|
.write(
|
|
"\(testCase.name) \("(\(Int(Double(testCase.duration)! * 1000))ms)", color: "[90m")\n"
|
|
) // gray
|
|
for problem in testCase.problems {
|
|
terminal.write("\n \(problem.file, color: "[90m"):\(problem.line)\n")
|
|
terminal.write(" \(problem.message)\n\n")
|
|
// Format XCTAssert functions
|
|
for assertion in Regex.Assertion.allCases {
|
|
if let (expected, received) = problem.message.match(
|
|
of: Regex.xctAssert(assertion),
|
|
labelled: .expected, .received
|
|
) {
|
|
terminal.write(" Expected: \("\(assertion.symbol)\(expected)", color: "[92m")\n")
|
|
terminal.write(" Received: \(received, color: "[91m")\n")
|
|
}
|
|
}
|
|
// Get the line of code from the file and output it for context.
|
|
if let lineNum = Int(problem.line),
|
|
lineNum > 0
|
|
{
|
|
var fileContents: String?
|
|
if let fileBuf = fileBufs.first(where: { $0.path == problem.file })?.contents {
|
|
fileContents = fileBuf
|
|
} else if let fileBuf = try? String(
|
|
contentsOf: URL(fileURLWithPath: problem.file),
|
|
encoding: .utf8
|
|
) {
|
|
fileContents = fileBuf
|
|
fileBufs.insert(.init(path: problem.file, contents: fileBuf))
|
|
}
|
|
if let fileContents = fileContents {
|
|
let fileLines = fileContents.components(separatedBy: .newlines)
|
|
guard fileLines.count >= lineNum else { break }
|
|
let highlightedCode = Self.highlighter.highlight(String(fileLines[lineNum - 1]))
|
|
terminal.write(" \("\(problem.line) | ", color: "[36m")\(highlightedCode)\n")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func flushSummary(of suites: [Suite], _ terminal: InteractiveWriter) {
|
|
let suitesWithCases = suites.filter { $0.cases.count > 0 }
|
|
|
|
terminal.write("Test Suites: ")
|
|
let suitesPassed = suitesWithCases.filter { $0.passed }.count
|
|
if suitesPassed > 0 {
|
|
terminal.write("\("\(suitesPassed) passed", color: "[32m"), ")
|
|
}
|
|
if suitesWithCases.count - suitesPassed > 0 {
|
|
terminal.write("\("\(suitesWithCases.count - suitesPassed) failed", color: "[31m"), ")
|
|
}
|
|
terminal.write("\(suitesWithCases.count) total\n")
|
|
|
|
terminal.write("Tests: ")
|
|
let allTests = suitesWithCases.map(\.cases).reduce([], +)
|
|
let testsPassed = allTests.filter { $0.passed }.count
|
|
if testsPassed > 0 {
|
|
terminal.write("\("\(testsPassed) passed", color: "[32m"), ")
|
|
}
|
|
if allTests.count - testsPassed > 0 {
|
|
terminal.write("\("\(allTests.count - testsPassed) failed", color: "[31m"), ")
|
|
}
|
|
terminal.write("\(allTests.count) total\n")
|
|
|
|
let totalDuration = allTests.compactMap { Double($0.duration) }.reduce(0, +)
|
|
terminal.write("Time: ")
|
|
terminal.write("\(String(format: "%.2f", totalDuration))s\n")
|
|
|
|
if suites.contains(where: { $0.name == "All tests" }) {
|
|
terminal.write("\("Ran all test suites.", color: "[90m")\n") // gray
|
|
}
|
|
}
|
|
}
|