diff options
| author | Leitwolf | 2026-11-01 15:29:13 +0300 |
|---|---|---|
| committer | GitHub | 2026-11-01 15:29:13 +0300 |
| commit | b7fe8c2061c98e7aec886e9b1e6282f48c3b5456 (patch) | |
| tree | f933300c29658c3eb89d126d74b6c30fdff9dd04 /extras/catch_amalgamated.cpp | |
| parent | 1559307a6ac7731ffd6a8e28147552b3987dbd93 (diff) | |
| parent | 569620dad14df7e13bb9fd31d256e9fd873ef6ec (diff) | |
| download | catch3-b7fe8c2061c98e7aec886e9b1e6282f48c3b5456.tar.gz | |
Merge pull request #28 from Progmasoft/feature/result-import
Import the results of other test frameworks
Diffstat (limited to 'extras/catch_amalgamated.cpp')
| -rw-r--r-- | extras/catch_amalgamated.cpp | 1958 |
1 files changed, 1863 insertions, 95 deletions
diff --git a/extras/catch_amalgamated.cpp b/extras/catch_amalgamated.cpp index 7556181e..01b07618 100644 --- a/extras/catch_amalgamated.cpp +++ b/extras/catch_amalgamated.cpp @@ -7,7 +7,7 @@ // SPDX-License-Identifier: BSL-1.0 // Catch3 v0.3.0, on the Catch2 v3.16.1 engine -// Generated: 2026-11-01 00:25:29.768099 +// Generated: 2026-11-01 14:47:44.734580 // ---------------------------------------------------------- // This file is an amalgamation of multiple different files. // You probably shouldn't edit it directly. @@ -965,6 +965,8 @@ namespace Progmasoft::Catch3::Runner kListTests, kListTags, kAnalytics, + /// Read the reports of other test frameworks. + kImport, kHelp, kVersion, /// Internal: run tests on behalf of a runner, over the protocol. @@ -1022,6 +1024,20 @@ namespace Progmasoft::Catch3::Runner kOff, }; + /// The reports `--import` reads. + enum class ImportFormat : std::uint8_t + { + /// Whichever the contents show. + kAuto, + /// Catch3's own: `--json` reports and catch3-results JSON Lines. + kCatch3, + kJUnit, + kXUnit, + kTrx, + kTap, + kCtrf, + }; + enum class ColorMode : std::uint8_t { kAuto, @@ -1101,6 +1117,15 @@ namespace Progmasoft::Catch3::Runner bool replayFailures = false; bool analyticsJson = false; + // Importing the reports of other frameworks. + std::vector<std::string> importFiles; + ImportFormat importFormat = ImportFormat::kAuto; + /// The history they go to; "imported" when empty. + std::string importName; + /// What was tested: a verdict that changes on one revision counts + /// as flaky. Without it, only retries in the reports do. + std::string importRevision; + // Output. Verbosity verbosity = Verbosity::kNormal; ColorMode color = ColorMode::kAuto; @@ -1268,6 +1293,10 @@ namespace Progmasoft::Catch3::Runner std::uint32_t seed, const std::vector<std::string> ¬es); + /// The start of `--import`: results read from `files` reports. + void + ImportStarting(std::size_t tests, std::size_t files); + void TestFinished(const TestResult &test); @@ -1856,6 +1885,96 @@ namespace Progmasoft::Catch3::Runner // Copyright (c) 2026 Progmasoft. // SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 +#include <cstdint> +#include <optional> +#include <string> +#include <string_view> +#include <vector> + + +namespace Progmasoft::Catch3::Runner +{ + /// The format of a report, from its contents; nothing when none fits. + [[nodiscard]] std::optional<ImportFormat> + DetectImportFormat(std::string_view text); + + /// The results a report holds, in its order. A test the report names + /// twice comes back twice; `MergeImported` makes one test of them. + /// `source` names the report, for a format whose tests have no identity + /// of their own. + [[nodiscard]] Expected<std::vector<TestResult>> + ParseImport(std::string_view text, + ImportFormat format, + const std::string &source = {}); + + /// One test per identifier: results that share one are attempts at that + /// test, in the order given, and the verdict is worked out over them. A + /// test that both failed and passed is flaky, as it is in a run of the + /// runner. + [[nodiscard]] std::vector<TestResult> + MergeImported(std::vector<TestResult> results); + + /// A result without attempts yet. + [[nodiscard]] TestResult + ImportedTest(std::string name, std::string className); + + /// One attempt as a report describes it. A failure gets an assertion + /// record with `message` and `details`, so the analysis treats it as it + /// treats a failure of Catch3's own; `category` defaults by outcome. + [[nodiscard]] Attempt + ImportedAttempt(Outcome outcome, + std::int64_t durationMicroseconds, + std::string message = {}, + std::string details = {}, + FailureCategory category = FailureCategory::kNone); + + /// Seconds as a report gives them; what makes no sense as a duration + /// counts as zero. + [[nodiscard]] std::int64_t + ImportedMicroseconds(double seconds); + + [[nodiscard]] std::string_view + ImportTrim(std::string_view text); + + [[nodiscard]] bool + ImportStartsWith(std::string_view text, std::string_view prefix); + + /// The lines of a text, without their line endings. + [[nodiscard]] std::vector<std::string_view> + ImportLines(std::string_view text); + + /// A format of Catch3's own or CTRF, from a parsed document. + [[nodiscard]] std::optional<ImportFormat> + ImportJsonFormat(const JsonValue &value); + + /// Whether a line reports a test in TAP. + [[nodiscard]] bool + IsTapTestLine(std::string_view line); + + /// A `--json` report or catch3-results lines. + [[nodiscard]] Expected<std::vector<TestResult>> + ParseCatch3Report(std::string_view text); + + [[nodiscard]] Expected<std::vector<TestResult>> + ParseCtrf(std::string_view text); + + /// `source` becomes the class of its tests: TAP names them only within + /// one stream. + [[nodiscard]] Expected<std::vector<TestResult>> + ParseTap(std::string_view text, const std::string &source); + + [[nodiscard]] Expected<std::vector<TestResult>> + ParseJUnit(std::string_view text); + + [[nodiscard]] Expected<std::vector<TestResult>> + ParseXUnit(std::string_view text); + + [[nodiscard]] Expected<std::vector<TestResult>> + ParseTrx(std::string_view text); +} // namespace Progmasoft::Catch3::Runner +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + namespace Progmasoft::Catch3::Runner @@ -9702,6 +9821,11 @@ namespace Progmasoft::Catch3::Runner test.analysis.insert( test.analysis.end(), findings.begin(), findings.end()); + // An imported failure ran somewhere else. + if (inputs.program.empty()) + { + continue; + } std::string replay = QuoteArgument(inputs.program) + " " + QuoteArgument(QuoteTestSpec(test.name)) + " --seed=" + std::to_string(failure->seed); @@ -10202,6 +10326,21 @@ namespace Progmasoft::Catch3::Runner } void + ConsoleReport::ImportStarting(std::size_t tests, std::size_t files) + { + total_ = tests; + if (options_.verbosity == Verbosity::kQuiet) + { + return; + } + Write(output_, + Paint("Catch3", kBold) + ": " + std::to_string(tests) + + " test(s) imported from " + std::to_string(files) + + " report(s)\n"); + std::fflush(output_); + } + + void ConsoleReport::Retrying(const TestResult &test, const Attempt &attempt) { if (options_.verbosity != Verbosity::kVerbose) @@ -13375,6 +13514,59 @@ namespace Progmasoft::Catch3::Runner options.replayFailures = true; return Status::Ok(); }}, + {"import", + '\0', + Takes::kValue, + "FILE", + "Importing results", + "read a report of another framework instead of running tests " + "(repeatable)", + [](Options &options, const std::optional<std::string> &value) { + options.mode = Mode::kImport; + options.importFiles.push_back(*value); + return Status::Ok(); + }}, + {"import-format", + '\0', + Takes::kValue, + "FORMAT", + "Importing results", + "auto (default), catch3, junit, xunit, trx, tap or ctrf", + [](Options &options, const std::optional<std::string> &value) { + return ReadChoice<ImportFormat>( + value, + "import-format", + {{"auto", ImportFormat::kAuto}, + {"catch3", ImportFormat::kCatch3}, + {"junit", ImportFormat::kJUnit}, + {"xunit", ImportFormat::kXUnit}, + {"trx", ImportFormat::kTrx}, + {"tap", ImportFormat::kTap}, + {"ctrf", ImportFormat::kCtrf}}, + options.importFormat); + }}, + {"import-name", + '\0', + Takes::kValue, + "NAME", + "Importing results", + "the history imported results go to (default: imported); " + "--analytics reads it too", + [](Options &options, const std::optional<std::string> &value) { + options.importName = *value; + return Status::Ok(); + }}, + {"import-revision", + '\0', + Takes::kValue, + "REVISION", + "Importing results", + "what the reports tested; a verdict that changes on one " + "revision counts as flaky", + [](Options &options, const std::optional<std::string> &value) { + options.importRevision = *value; + return Status::Ok(); + }}, {"analytics", '\0', Takes::kOptionalValue, @@ -15881,6 +16073,10 @@ namespace Progmasoft::Catch3::Runner "break", "replay", "analytics", + "import", + "import-format", + "import-name", + "import-revision", "verbose", "quiet", "premature-exit-guard-file", @@ -16379,6 +16575,1438 @@ namespace Progmasoft::Catch3::Runner } // namespace Progmasoft::Catch3::Runner // Copyright (c) 2026 Progmasoft. // SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 +#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 &source) + { + 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, source); + 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, source); + } + + 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; + } + // A report of a skip says nothing about an earlier failure. + const auto verdict + = std::find_if(test.attempts.rbegin(), + test.attempts.rend(), + [](const Attempt &attempt) { + return IsFailure(attempt.outcome); + }); + const Attempt &last = verdict != test.attempts.rend() + ? *verdict + : test.attempts.back(); + test.outcome = last.outcome; + test.category = last.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 +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + +// The JSON reports: Catch3's own and CTRF. +#include <utility> + +namespace Progmasoft::Catch3::Runner +{ + namespace + { + namespace ImportJson + { + /// A format of Catch3's own or CTRF, from the parsed document. + std::optional<ImportFormat> + FormatOf(const JsonValue &value) + { + if (value["format"].AsString() == "catch3-run" + || value["format"].AsString() == "catch3-results") + { + return ImportFormat::kCatch3; + } + if (value["results"]["tests"].IsArray()) + { + return ImportFormat::kCtrf; + } + return std::nullopt; + } + + /// What a test owes the verdict of an earlier run is worked out + /// again here, from this run's point of view. + void + Forget(TestResult &test) + { + test.quarantined = false; + test.history = FailureHistory::kUnknown; + test.selectionReason.clear(); + test.signature.clear(); + test.analysis.clear(); + test.replayCommand.clear(); + test.predictedDurationMicroseconds = 0; + test.predictedFailureProbability = 0.0; + } + + Expected<TestResult> + ReadLine(const JsonValue &value) + { + TestResult test = TestResultFromJson(value); + Forget(test); + if (test.name.empty()) + { + test.name = test.id; + } + if (test.name.empty()) + { + return Error{ "a test needs an 'id' or a 'name'" }; + } + const std::string &outcome = value["outcome"].AsString(); + if (!OutcomeFromName(outcome)) + { + return Error{ "'" + test.name + "' has the outcome '" + + outcome + "'" }; + } + if (test.attempts.empty()) + { + Attempt attempt = ImportedAttempt( + test.outcome, + value["durationMicroseconds"].AsInteger(), + value["message"].AsString(), + value["details"].AsString(), + test.category); + attempt.standardOutput = value["standardOutput"].AsString(); + attempt.standardError = value["standardError"].AsString(); + test.attempts.push_back(std::move(attempt)); + } + return test; + } + + Expected<std::vector<TestResult>> + ParseLines(std::string_view text) + { + std::vector<TestResult> tests; + std::size_t number = 0; + bool started = false; + for (const auto line : ImportLines(text)) + { + ++number; + if (ImportTrim(line).empty()) + { + continue; + } + const std::string where = "line " + std::to_string(number); + auto value = ParseJson(line); + if (!value) + { + return value.GetError().Context(where); + } + if (!started) + { + started = true; + if ((*value)["format"].AsString() != "catch3-results") + { + return Error{ "the first line must name the format " + "catch3-results" }; + } + if ((*value)["version"].AsInteger() + > kResultsFormatVersion) + { + return Error{ "the format is newer than this " + "version of Catch3 reads" }; + } + continue; + } + auto test = ReadLine(*value); + if (!test) + { + return test.GetError().Context(where); + } + tests.push_back(std::move(*test)); + } + return tests; + } + + Expected<std::vector<TestResult>> + ParseCatch3(std::string_view text) + { + auto whole = ParseJson(text); + if (!whole) + { + return ParseLines(text); + } + if ((*whole)["format"].AsString() != "catch3-run") + { + return ParseLines(text); + } + auto run = RunResultFromJson(*whole); + if (!run) + { + return run.GetError(); + } + for (auto &test : run->tests) + { + Forget(test); + } + return std::move(run->tests); + } + + std::string + TextOf(const JsonValue &value) + { + if (!value.IsArray()) + { + return value.AsString(); + } + std::string text; + for (const auto &item : value.Items()) + { + text += item.AsString() + "\n"; + } + return text; + } + + Outcome + CtrfOutcome(const std::string &status) + { + if (status == "passed") + { + return Outcome::kPassed; + } + if (status == "failed") + { + return Outcome::kFailed; + } + if (status == "skipped" || status == "pending") + { + return Outcome::kSkipped; + } + return Outcome::kNotRun; + } + + /// A test, or one retry of it: the two share these fields. + Attempt + CtrfAttempt(const JsonValue &value) + { + Attempt attempt = ImportedAttempt( + CtrfOutcome(value["status"].AsString()), + ImportedMicroseconds(value["duration"].AsNumber() / 1e3), + value["message"].AsString(), + value["trace"].AsString()); + attempt.standardOutput = TextOf(value["stdout"]); + attempt.standardError = TextOf(value["stderr"]); + return attempt; + } + + std::string + CtrfSuite(const JsonValue &suite) + { + if (!suite.IsArray()) + { + return suite.AsString(); + } + std::string text; + for (const auto &item : suite.Items()) + { + text += (text.empty() ? "" : " > ") + item.AsString(); + } + return text; + } + + Expected<std::vector<TestResult>> + ParseCtrf(std::string_view text) + { + auto document = ParseJson(text); + if (!document) + { + return document.GetError(); + } + const JsonValue &tests = (*document)["results"]["tests"]; + if (!tests.IsArray()) + { + return Error{ "a CTRF report has results.tests" }; + } + std::vector<TestResult> results; + for (const auto &item : tests.Items()) + { + const std::string &name = item["name"].AsString(); + if (name.empty()) + { + return Error{ "a CTRF test has no name" }; + } + TestResult test + = ImportedTest(name, CtrfSuite(item["suite"])); + test.file = item["filePath"].AsString(); + test.line + = static_cast<std::size_t>(item["line"].AsUnsigned()); + test.flaky = item["flaky"].AsBoolean(); + for (const auto &tag : item["tags"].Items()) + { + test.tags.push_back(tag.AsString()); + } + for (const auto &retry : item["retryAttempts"].Items()) + { + test.attempts.push_back(CtrfAttempt(retry)); + } + // The retries end before the last attempt, which is the + // test itself. + test.attempts.push_back(CtrfAttempt(item)); + results.push_back(std::move(test)); + } + return results; + } + } // namespace ImportJson + } // namespace + + std::optional<ImportFormat> + ImportJsonFormat(const JsonValue &value) + { + return ImportJson::FormatOf(value); + } + + Expected<std::vector<TestResult>> + ParseCatch3Report(std::string_view text) + { + return ImportJson::ParseCatch3(text); + } + + Expected<std::vector<TestResult>> + ParseCtrf(std::string_view text) + { + return ImportJson::ParseCtrf(text); + } +} // namespace Progmasoft::Catch3::Runner +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0 + +// The Test Anything Protocol, versions 12 to 14. +#include <algorithm> +#include <cctype> +#include <cstdlib> +#include <map> +#include <utility> + +namespace Progmasoft::Catch3::Runner +{ + namespace + { + namespace ImportTap + { + std::string + Lowered(std::string_view text) + { + std::string lowered{ text }; + std::transform(lowered.begin(), + lowered.end(), + lowered.begin(), + [](unsigned char character) { + return static_cast<char>( + std::tolower(character)); + }); + return lowered; + } + + struct Line final + { + bool ok = false; + std::string description; + std::string directive; + std::string reason; + }; + + /// Where the `#` that starts a directive is: `\#` is part of the + /// description. + std::size_t + DirectiveStart(std::string_view text) + { + for (std::size_t index = 0; index < text.size(); ++index) + { + if (text[index] == '\\') + { + ++index; + } + else if (text[index] == '#') + { + return index; + } + } + return std::string_view::npos; + } + + std::string + Unescaped(std::string_view text) + { + std::string result; + for (std::size_t index = 0; index < text.size(); ++index) + { + if (text[index] == '\\' && index + 1 < text.size()) + { + ++index; + } + result.push_back(text[index]); + } + return result; + } + + /// Reads a test line; nothing when `text` is not one. + std::optional<Line> + ReadLine(std::string_view text) + { + |