Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/src/Progmasoft/Runner/ResultImport.cpp
diff options
context:
space:
mode:
authorLeitwolf112026-11-01 14:23:09 +0300
committerLeitwolf112026-11-01 14:23:09 +0300
commit1db203d56b7c71db3d80b477da1ad49e9ae46468 (patch)
tree48cd538e23e40ebdaae034a864082de30f68364f /src/Progmasoft/Runner/ResultImport.cpp
parent1559307a6ac7731ffd6a8e28147552b3987dbd93 (diff)
downloadcatch3-1db203d56b7c71db3d80b477da1ad49e9ae46468.tar.gz
Import the results of other test frameworks
Tests Catch3 does not run had no history, flaky detection or quarantine. --import=FILE reads a report instead of running tests and sends it through the analysis, history and reports of a run: JUnit with Surefire's reruns, xUnit.net v2, TRX, TAP 12 to 14, CTRF, Catch3's own --json report and catch3-results, a small JSON Lines format for tools with none of these. Results that share an identifier are attempts at one test, so a report of a rerun gives a flaky test. --import-revision says what was tested: a verdict that changes on one revision also counts as flaky. --import-name keeps the results in a history of their own, which --analytics reads too. A TAP stream that ends early fails instead of reading as a run that passed. The amalgamation script now accepts a relative include of the third-party wrappers, because pugixml is included from a second directory.
Diffstat (limited to 'src/Progmasoft/Runner/ResultImport.cpp')
-rw-r--r--src/Progmasoft/Runner/ResultImport.cpp305
1 files changed, 305 insertions, 0 deletions
diff --git a/src/Progmasoft/Runner/ResultImport.cpp b/src/Progmasoft/Runner/ResultImport.cpp
new file mode 100644
index 00000000..10a9c5a8
--- /dev/null
+++ b/src/Progmasoft/Runner/ResultImport.cpp
@@ -0,0 +1,305 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+#include <Progmasoft/Runner/History.hpp>
+#include <Progmasoft/Runner/Json.hpp>
+#include <Progmasoft/Runner/ResultImport.hpp>
+#include <algorithm>
+#include <cctype>
+#include <cmath>
+#include <iterator>
+#include <map>
+#include <utility>
+
+namespace Progmasoft::Catch3::Runner
+{
+ std::string_view
+ ImportTrim(std::string_view text)
+ {
+ const auto blank = [](char character) {
+ return std::isspace(static_cast<unsigned char>(character)) != 0;
+ };
+ while (!text.empty() && blank(text.front()))
+ {
+ text.remove_prefix(1);
+ }
+ while (!text.empty() && blank(text.back()))
+ {
+ text.remove_suffix(1);
+ }
+ return text;
+ }
+
+ bool
+ ImportStartsWith(std::string_view text, std::string_view prefix)
+ {
+ return text.substr(0, prefix.size()) == prefix;
+ }
+
+ std::vector<std::string_view>
+ ImportLines(std::string_view text)
+ {
+ std::vector<std::string_view> lines;
+ while (!text.empty())
+ {
+ const auto end = text.find('\n');
+ auto line = text.substr(0, end);
+ if (!line.empty() && line.back() == '\r')
+ {
+ line.remove_suffix(1);
+ }
+ lines.push_back(line);
+ if (end == std::string_view::npos)
+ {
+ break;
+ }
+ text.remove_prefix(end + 1);
+ }
+ return lines;
+ }
+
+ namespace
+ {
+ namespace ImportText
+ {
+ /// Without a byte order mark and the white space before the
+ /// first character that counts.
+ std::string_view
+ Contents(std::string_view text)
+ {
+ if (ImportStartsWith(text, "\xEF\xBB\xBF"))
+ {
+ text.remove_prefix(3);
+ }
+ return ImportTrim(text);
+ }
+
+ /// The name of the root element of an XML document.
+ std::string_view
+ RootElement(std::string_view text)
+ {
+ while (!text.empty())
+ {
+ const auto open = text.find('<');
+ if (open == std::string_view::npos
+ || open + 1 >= text.size())
+ {
+ return {};
+ }
+ text.remove_prefix(open + 1);
+ if (text.front() == '?' || text.front() == '!')
+ {
+ const auto close = text.find('>');
+ if (close == std::string_view::npos)
+ {
+ return {};
+ }
+ text.remove_prefix(close + 1);
+ continue;
+ }
+ return text.substr(0, text.find_first_of(" \t\r\n/>"));
+ }
+ return {};
+ }
+ } // namespace ImportText
+ } // namespace
+
+ std::optional<ImportFormat>
+ DetectImportFormat(std::string_view text)
+ {
+ const std::string_view contents = ImportText::Contents(text);
+ if (contents.empty())
+ {
+ return std::nullopt;
+ }
+ if (contents.front() == '<')
+ {
+ const std::string_view root = ImportText::RootElement(contents);
+ if (root == "testsuites" || root == "testsuite")
+ {
+ return ImportFormat::kJUnit;
+ }
+ if (root == "assemblies" || root == "assembly")
+ {
+ return ImportFormat::kXUnit;
+ }
+ if (root == "TestRun")
+ {
+ return ImportFormat::kTrx;
+ }
+ return std::nullopt;
+ }
+ if (contents.front() == '{')
+ {
+ auto whole = ParseJson(contents);
+ if (!whole)
+ {
+ // JSON Lines: the first line says what the others are.
+ whole = ParseJson(contents.substr(0, contents.find('\n')));
+ }
+ return whole ? ImportJsonFormat(*whole) : std::nullopt;
+ }
+ for (const auto raw : ImportLines(contents))
+ {
+ const std::string_view line = ImportTrim(raw);
+ if (line.empty() || line.front() == '#')
+ {
+ continue;
+ }
+ if (ImportStartsWith(line, "TAP version")
+ || ImportStartsWith(line, "1..") || IsTapTestLine(line))
+ {
+ return ImportFormat::kTap;
+ }
+ break;
+ }
+ return std::nullopt;
+ }
+
+ Expected<std::vector<TestResult>>
+ ParseImport(std::string_view text, ImportFormat format)
+ {
+ const std::string_view contents = ImportText::Contents(text);
+ switch (format)
+ {
+ case ImportFormat::kAuto:
+ break;
+ case ImportFormat::kCatch3:
+ return ParseCatch3Report(contents);
+ case ImportFormat::kJUnit:
+ return ParseJUnit(contents);
+ case ImportFormat::kXUnit:
+ return ParseXUnit(contents);
+ case ImportFormat::kTrx:
+ return ParseTrx(contents);
+ case ImportFormat::kTap:
+ return ParseTap(contents);
+ case ImportFormat::kCtrf:
+ return ParseCtrf(contents);
+ }
+ const auto detected = DetectImportFormat(contents);
+ if (!detected)
+ {
+ return Error{ "its format is not one Catch3 knows; "
+ "--import-format names it" };
+ }
+ return ParseImport(contents, *detected);
+ }
+
+ std::vector<TestResult>
+ MergeImported(std::vector<TestResult> results)
+ {
+ std::vector<TestResult> tests;
+ std::map<std::string, std::size_t> positions;
+ for (auto &result : results)
+ {
+ if (result.id.empty())
+ {
+ result.id = History::KeyOf(result);
+ }
+ const auto [position, added]
+ = positions.emplace(result.id, tests.size());
+ if (added)
+ {
+ tests.push_back(std::move(result));
+ continue;
+ }
+ TestResult &test = tests[position->second];
+ test.flaky = test.flaky || result.flaky;
+ std::move(result.attempts.begin(),
+ result.attempts.end(),
+ std::back_inserter(test.attempts));
+ }
+ for (auto &test : tests)
+ {
+ std::size_t passes = 0;
+ std::size_t failures = 0;
+ for (std::size_t index = 0; index < test.attempts.size(); ++index)
+ {
+ Attempt &attempt = test.attempts[index];
+ attempt.number = index + 1;
+ passes += attempt.outcome == Outcome::kPassed ? 1 : 0;
+ failures += IsFailure(attempt.outcome) ? 1 : 0;
+ }
+ if (test.attempts.empty())
+ {
+ continue;
+ }
+ test.outcome = test.attempts.back().outcome;
+ test.category = test.attempts.back().category;
+ if (passes > 0 && failures > 0)
+ {
+ test.outcome = Outcome::kPassed;
+ test.category = FailureCategory::kNone;
+ test.flaky = true;
+ test.analysis.push_back(
+ "flaky: failed " + std::to_string(failures) + " of "
+ + std::to_string(test.attempts.size()) + " attempts");
+ }
+ }
+ return tests;
+ }
+
+ TestResult
+ ImportedTest(std::string name, std::string className)
+ {
+ TestResult test;
+ test.name = std::move(name);
+ test.className = std::move(className);
+ test.id = History::KeyOf(test);
+ return test;
+ }
+
+ Attempt
+ ImportedAttempt(Outcome outcome,
+ std::int64_t durationMicroseconds,
+ std::string message,
+ std::string details,
+ FailureCategory category)
+ {
+ Attempt attempt;
+ attempt.outcome = outcome;
+ attempt.durationMicroseconds
+ = std::max<std::int64_t>(durationMicroseconds, 0);
+ if (outcome == Outcome::kSkipped)
+ {
+ attempt.standardOutput = std::move(message);
+ return attempt;
+ }
+ if (outcome != Outcome::kFailedAsExpected && !IsFailure(outcome))
+ {
+ return attempt;
+ }
+ if (category == FailureCategory::kNone)
+ {
+ category = outcome == Outcome::kCrashed ? FailureCategory::kCrash
+ : outcome == Outcome::kTimedOut
+ ? FailureCategory::kTimeout
+ : FailureCategory::kAssertion;
+ }
+ if (message.empty())
+ {
+ message = details.substr(0, details.find('\n'));
+ }
+ attempt.category = category;
+ attempt.assertionsFailed = 1;
+ AssertionRecord assertion;
+ assertion.message = std::move(message);
+ assertion.expansion = std::move(details);
+ assertion.category = category;
+ assertion.allowed = outcome == Outcome::kFailedAsExpected;
+ attempt.assertions.push_back(std::move(assertion));
+ return attempt;
+ }
+
+ std::int64_t
+ ImportedMicroseconds(double seconds)
+ {
+ // About thirty years; a longer duration is not a duration.
+ constexpr double kLongest = 1e9;
+ if (!std::isfinite(seconds) || seconds <= 0.0 || seconds > kLongest)
+ {
+ return 0;
+ }
+ return static_cast<std::int64_t>(std::llround(seconds * 1e6));
+ }
+} // namespace Progmasoft::Catch3::Runner