diff options
| author | Leitwolf11 | 2026-09-24 23:05:47 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-09-24 23:05:47 +0300 |
| commit | d79b294903e4d0f4b24fdf1cc50be4b0fec7f745 (patch) | |
| tree | 64f0c7c3dfbc08bfb70d9a37df5cccae3d52b526 /src/Progmasoft | |
| parent | 9ad804cedf4cc4a85ef89fe097f97a3b44654360 (diff) | |
| download | catch3-0.2.0.tar.gz | |
Add snapshots and JUnit result serialization0.2.0
Diffstat (limited to 'src/Progmasoft')
| -rw-r--r-- | src/Progmasoft/BUILD.bazel | 12 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3.hpp | 3 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/Assertions.hpp | 10 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/Results.hpp | 57 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/Snapshot.hpp | 55 | ||||
| -rw-r--r-- | src/Progmasoft/Catch3/XmlWriter.hpp | 20 | ||||
| -rw-r--r-- | src/Progmasoft/Results.cpp | 73 | ||||
| -rw-r--r-- | src/Progmasoft/Snapshot.cpp | 265 | ||||
| -rw-r--r-- | src/Progmasoft/XmlWriter.cpp | 274 | ||||
| -rw-r--r-- | src/Progmasoft/meson.build | 15 |
10 files changed, 783 insertions, 1 deletions
diff --git a/src/Progmasoft/BUILD.bazel b/src/Progmasoft/BUILD.bazel index 9927aafa..642bc158 100644 --- a/src/Progmasoft/BUILD.bazel +++ b/src/Progmasoft/BUILD.bazel @@ -6,6 +6,11 @@ package(default_visibility = ["//visibility:public"]) cc_library( name = "catch3", + srcs = [ + "Results.cpp", + "Snapshot.cpp", + "XmlWriter.cpp", + ], hdrs = [ "Catch3.hpp", "Catch3/Assertions.hpp", @@ -14,7 +19,14 @@ cc_library( "Catch3/Generators/Integer.hpp", "Catch3/Property.hpp", "Catch3/Random.hpp", + "Catch3/Results.hpp", + "Catch3/Snapshot.hpp", + "Catch3/XmlWriter.hpp", ], + copts = select({ + "@platforms//os:windows": ["/std:c++20"], + "//conditions:default": ["-std=c++20"], + }), strip_include_prefix = "/src", deps = ["//:catch2"], ) diff --git a/src/Progmasoft/Catch3.hpp b/src/Progmasoft/Catch3.hpp index 1ae73e53..d2d34e74 100644 --- a/src/Progmasoft/Catch3.hpp +++ b/src/Progmasoft/Catch3.hpp @@ -11,3 +11,6 @@ #include <Progmasoft/Catch3/Generators/Integer.hpp> #include <Progmasoft/Catch3/Property.hpp> #include <Progmasoft/Catch3/Random.hpp> +#include <Progmasoft/Catch3/Results.hpp> +#include <Progmasoft/Catch3/Snapshot.hpp> +#include <Progmasoft/Catch3/XmlWriter.hpp> diff --git a/src/Progmasoft/Catch3/Assertions.hpp b/src/Progmasoft/Catch3/Assertions.hpp index 19ac13ef..668c086d 100644 --- a/src/Progmasoft/Catch3/Assertions.hpp +++ b/src/Progmasoft/Catch3/Assertions.hpp @@ -6,6 +6,7 @@ #include <catch2/catch_test_macros.hpp> #include <Progmasoft/Catch3/Property.hpp> +#include <Progmasoft/Catch3/Snapshot.hpp> /// Runs a property and reports its replay seed and minimized counterexample. #define CATCH3_CHECK_PROPERTY( ... ) \ @@ -15,3 +16,12 @@ INFO( catch3PropertyResult.Describe() ); \ CHECK( catch3PropertyResult.Succeeded() ); \ } while ( false ) + +/// Compares a value with a named snapshot and reports the first difference. +#define CATCH3_CHECK_SNAPSHOT( name, actual, options ) \ + do { \ + const auto catch3SnapshotResult = \ + ::Progmasoft::Catch3::CompareSnapshot( name, actual, options ); \ + INFO( catch3SnapshotResult.Message ); \ + CHECK( catch3SnapshotResult.Succeeded() ); \ + } while ( false ) diff --git a/src/Progmasoft/Catch3/Results.hpp b/src/Progmasoft/Catch3/Results.hpp new file mode 100644 index 00000000..b0b69b55 --- /dev/null +++ b/src/Progmasoft/Catch3/Results.hpp @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#pragma once + +#include <cstddef> +#include <string> +#include <vector> + +namespace Progmasoft::Catch3 { + + enum class TestCaseStatus : unsigned char { + kPassed, + kFailed, + kErrored, + kSkipped, + }; + + /// Framework-neutral result for one completed test case. + struct TestCaseResult final { + std::string Name; + std::string ClassName; + std::string SourceFile; + TestCaseStatus Status = TestCaseStatus::kPassed; + double DurationSeconds = 0.0; + std::string Message; + std::string Details; + std::string StandardOutput; + std::string StandardError; + }; + + struct TestSuiteResult final { + std::string Name; + std::vector<TestCaseResult> TestCases; + }; + + struct TestRunResult final { + std::string Name; + std::vector<TestSuiteResult> TestSuites; + }; + + struct TestRunSummary final { + std::size_t Tests = 0; + std::size_t Failures = 0; + std::size_t Errors = 0; + std::size_t Skipped = 0; + double DurationSeconds = 0.0; + + [[nodiscard]] std::size_t Passed() const noexcept; + }; + + /// Validates durations and statuses while aggregating nested test results. + [[nodiscard]] TestRunSummary + SummarizeTestSuite( const TestSuiteResult& result ); + [[nodiscard]] TestRunSummary + SummarizeTestRun( const TestRunResult& result ); + +} // namespace Progmasoft::Catch3 diff --git a/src/Progmasoft/Catch3/Snapshot.hpp b/src/Progmasoft/Catch3/Snapshot.hpp new file mode 100644 index 00000000..09863c1c --- /dev/null +++ b/src/Progmasoft/Catch3/Snapshot.hpp @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#pragma once + +#include <cstddef> +#include <filesystem> +#include <string> +#include <string_view> + +namespace Progmasoft::Catch3 { + + /// Updates are deliberately explicit so normal test runs never rewrite + /// files. + enum class SnapshotUpdateMode : unsigned char { + kNever, + kCreateMissing, + kAlways, + }; + + enum class SnapshotStatus : unsigned char { + kMatched, + kCreated, + kUpdated, + kMismatched, + kInvalidName, + kInvalidOptions, + kIoError, + }; + + struct SnapshotOptions final { + std::filesystem::path Directory; + SnapshotUpdateMode UpdateMode = SnapshotUpdateMode::kNever; + bool NormalizeLineEndings = true; + }; + + /// Structured comparison result; DifferenceLine/Column are one-based. + struct SnapshotResult final { + SnapshotStatus Status = SnapshotStatus::kInvalidOptions; + std::filesystem::path File; + std::string Expected; + std::string Actual; + std::string Message; + std::size_t DifferenceLine = 0; + std::size_t DifferenceColumn = 0; + + [[nodiscard]] bool Succeeded() const noexcept; + }; + + /// Compare against `<Directory>/<name>.snap`; names cannot contain paths. + [[nodiscard]] SnapshotResult + CompareSnapshot( std::string_view name, + std::string_view actual, + const SnapshotOptions& options ); + +} // namespace Progmasoft::Catch3 diff --git a/src/Progmasoft/Catch3/XmlWriter.hpp b/src/Progmasoft/Catch3/XmlWriter.hpp new file mode 100644 index 00000000..3216b5ae --- /dev/null +++ b/src/Progmasoft/Catch3/XmlWriter.hpp @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#pragma once + +#include <iosfwd> +#include <string> + +namespace Progmasoft::Catch3 { + + struct TestRunResult; + + /// Emits deterministic JUnit-style XML without timestamps or global state. + class JUnitXmlWriter final { + public: + static void Write( const TestRunResult& result, std::ostream& output ); + [[nodiscard]] static std::string + ToString( const TestRunResult& result ); + }; + +} // namespace Progmasoft::Catch3 diff --git a/src/Progmasoft/Results.cpp b/src/Progmasoft/Results.cpp new file mode 100644 index 00000000..69a819a7 --- /dev/null +++ b/src/Progmasoft/Results.cpp @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#include <Progmasoft/Catch3/Results.hpp> + +#include <cmath> +#include <limits> +#include <stdexcept> + +namespace Progmasoft::Catch3 { + + namespace { + + void AccumulateCases( TestRunSummary& summary, + const std::vector<TestCaseResult>& testCases ) { + for ( const TestCaseResult& testCase : testCases ) { + if ( !std::isfinite( testCase.DurationSeconds ) || + testCase.DurationSeconds < 0.0 ) { + throw std::invalid_argument( + "test duration must be finite and non-negative" ); + } + if ( summary.Tests == + std::numeric_limits<std::size_t>::max() ) { + throw std::overflow_error( "test count overflow" ); + } + + ++summary.Tests; + summary.DurationSeconds += testCase.DurationSeconds; + if ( !std::isfinite( summary.DurationSeconds ) ) { + throw std::overflow_error( "test duration total overflow" ); + } + + switch ( testCase.Status ) { + case TestCaseStatus::kPassed: + break; + case TestCaseStatus::kFailed: + ++summary.Failures; + break; + case TestCaseStatus::kErrored: + ++summary.Errors; + break; + case TestCaseStatus::kSkipped: + ++summary.Skipped; + break; + default: + throw std::invalid_argument( "unknown test case status" ); + } + } + } + + } // namespace + + std::size_t TestRunSummary::Passed() const noexcept { + if ( Failures > Tests || Errors > Tests - Failures ) { return 0; } + const std::size_t failuresAndErrors = Failures + Errors; + if ( Skipped > Tests - failuresAndErrors ) { return 0; } + return Tests - failuresAndErrors - Skipped; + } + + TestRunSummary SummarizeTestSuite( const TestSuiteResult& result ) { + TestRunSummary summary; + AccumulateCases( summary, result.TestCases ); + return summary; + } + + TestRunSummary SummarizeTestRun( const TestRunResult& result ) { + TestRunSummary summary; + for ( const TestSuiteResult& suite : result.TestSuites ) { + AccumulateCases( summary, suite.TestCases ); + } + return summary; + } + +} // namespace Progmasoft::Catch3 diff --git a/src/Progmasoft/Snapshot.cpp b/src/Progmasoft/Snapshot.cpp new file mode 100644 index 00000000..7278eee3 --- /dev/null +++ b/src/Progmasoft/Snapshot.cpp @@ -0,0 +1,265 @@ +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#include <Progmasoft/Catch3/Snapshot.hpp> + +#include <atomic> +#include <chrono> +#include <cstdint> +#include <fstream> +#include <iterator> +#include <system_error> + +#if defined( _WIN32 ) +# if !defined( NOMINMAX ) +# define NOMINMAX +# endif +# include <Windows.h> +#endif +#include <utility> + +namespace Progmasoft::Catch3 { + + namespace { + + bool IsSafeSnapshotName( std::string_view name ) noexcept { + if ( name.empty() || name == "." || name == ".." ) { return false; } + for ( const unsigned char character : name ) { + const bool isLetter = + ( character >= 'a' && character <= 'z' ) || + ( character >= 'A' && character <= 'Z' ); + const bool isDigit = character >= '0' && character <= '9'; + if ( !isLetter && !isDigit && character != '_' && + character != '-' && character != '.' ) { + return false; + } + } + return true; + } + + std::string NormalizeSnapshotText( std::string_view text, + bool normalizeLineEndings ) { + if ( !normalizeLineEndings ) { return std::string( text ); } + + std::string normalized; + normalized.reserve( text.size() ); + for ( std::size_t index = 0; index < text.size(); ++index ) { + if ( text[index] == '\r' ) { + if ( index + 1 < text.size() && text[index + 1] == '\n' ) { + ++index; + } + normalized.push_back( '\n' ); + } else { + normalized.push_back( text[index] ); + } + } + return normalized; + } + + void LocateDifference( SnapshotResult& result ) noexcept { + const std::size_t commonLength = + result.Expected.size() < result.Actual.size() + ? result.Expected.size() + : result.Actual.size(); + std::size_t index = 0; + while ( index < commonLength && + result.Expected[index] == result.Actual[index] ) { + ++index; + } + + result.DifferenceLine = 1; + result.DifferenceColumn = 1; + for ( std::size_t offset = 0; offset < index; ++offset ) { + if ( result.Actual[offset] == '\n' ) { + ++result.DifferenceLine; + result.DifferenceColumn = 1; + } else { + ++result.DifferenceColumn; + } + } + } + + bool WriteAtomically( const std::filesystem::path& destination, + std::string_view contents, + bool replaceExisting, + std::error_code& error ) { + // Write beside the destination so replacement stays on one volume + // and readers never observe a partially written snapshot. + static std::atomic<std::uint64_t> nextTemporaryFile{ 0 }; + const auto clockValue = + std::chrono::steady_clock::now().time_since_epoch().count(); + const auto sequence = + nextTemporaryFile.fetch_add( 1, std::memory_order_relaxed ); + std::filesystem::path temporary = destination; + temporary += ".tmp." + std::to_string( clockValue ) + "." + + std::to_string( sequence ); + + { + std::ofstream output( temporary, + std::ios::binary | std::ios::trunc ); + if ( !output ) { + error = std::make_error_code( std::errc::io_error ); + return false; + } + output.write( contents.data(), + static_cast<std::streamsize>( contents.size() ) ); + output.flush(); + if ( !output ) { + error = std::make_error_code( std::errc::io_error ); + output.close(); + std::error_code ignored; + std::filesystem::remove( temporary, ignored ); + return false; + } + } + + if ( replaceExisting ) { +#if defined( _WIN32 ) + if ( MoveFileExW( temporary.c_str(), + destination.c_str(), + MOVEFILE_REPLACE_EXISTING | + MOVEFILE_WRITE_THROUGH ) == 0 ) { + error = std::error_code( static_cast<int>( GetLastError() ), + std::system_category() ); + } +#else + std::filesystem::rename( temporary, destination, error ); +#endif + } else { + // A hard-link create is atomic and refuses a concurrent file. + std::filesystem::create_hard_link( + temporary, destination, error ); + std::error_code ignored; + std::filesystem::remove( temporary, ignored ); + } + if ( error ) { + std::error_code ignored; + std::filesystem::remove( temporary, ignored ); + return false; + } + return true; + } + + } // namespace + + bool SnapshotResult::Succeeded() const noexcept { + return Status == SnapshotStatus::kMatched || + Status == SnapshotStatus::kCreated || + Status == SnapshotStatus::kUpdated; + } + + SnapshotResult CompareSnapshot( std::string_view name, + std::string_view actual, + const SnapshotOptions& options ) { + SnapshotResult result; + if ( !IsSafeSnapshotName( name ) ) { + result.Status = SnapshotStatus::kInvalidName; + result.Message = + "snapshot name must be a non-empty ASCII filename component"; + return result; + } + if ( options.Directory.empty() || + ( options.UpdateMode != SnapshotUpdateMode::kNever && + options.UpdateMode != SnapshotUpdateMode::kCreateMissing && + options.UpdateMode != SnapshotUpdateMode::kAlways ) ) { + result.Status = SnapshotStatus::kInvalidOptions; + result.Message = "snapshot directory or update mode is invalid"; + return result; + } + + result.File = options.Directory / ( std::string( name ) + ".snap" ); + result.Actual = + NormalizeSnapshotText( actual, options.NormalizeLineEndings ); + + std::error_code error; + const std::filesystem::file_status fileStatus = + std::filesystem::symlink_status( result.File, error ); + if ( error && error != std::errc::no_such_file_or_directory ) { + result.Status = SnapshotStatus::kIoError; + result.Message = + "could not inspect snapshot file: " + error.message(); + return result; + } + + const bool exists = !error && fileStatus.type() != + std::filesystem::file_type::not_found; + if ( exists && std::filesystem::is_symlink( fileStatus ) ) { + result.Status = SnapshotStatus::kIoError; + result.Message = "snapshot files must not be symbolic links"; + return result; + } + if ( exists && !std::filesystem::is_regular_file( fileStatus ) ) { + result.Status = SnapshotStatus::kIoError; + result.Message = "snapshot path is not a regular file"; + return result; + } + + if ( !exists ) { + if ( options.UpdateMode == SnapshotUpdateMode::kNever ) { + result.Status = SnapshotStatus::kMismatched; + result.Message = "snapshot file does not exist"; + LocateDifference( result ); + return result; + } + + std::filesystem::create_directories( options.Directory, error ); + if ( error || !WriteAtomically( + result.File, result.Actual, false, error ) ) { + result.Status = SnapshotStatus::kIoError; + result.Message = + "could not create snapshot file: " + error.message(); + return result; + } + result.Status = SnapshotStatus::kCreated; + result.Message = "snapshot created"; + return result; + } + + std::ifstream input( result.File, std::ios::binary ); + if ( !input ) { + result.Status = SnapshotStatus::kIoError; + result.Message = "could not open snapshot file for reading"; + return result; + } + const std::string fileContents{ std::istreambuf_iterator<char>( input ), + std::istreambuf_iterator<char>() }; + if ( input.bad() ) { + result.Status = SnapshotStatus::kIoError; + result.Message = "could not read snapshot file"; + return result; + } + input.close(); + if ( input.fail() ) { + result.Status = SnapshotStatus::kIoError; + result.Message = "could not close snapshot file after reading"; + return result; + } + result.Expected = + NormalizeSnapshotText( fileContents, options.NormalizeLineEndings ); + + if ( result.Expected == result.Actual ) { + result.Status = SnapshotStatus::kMatched; + result.Message = "snapshot matched"; + return result; + } + + LocateDifference( result ); + if ( options.UpdateMode == SnapshotUpdateMode::kAlways ) { + if ( !WriteAtomically( result.File, result.Actual, true, error ) ) { + result.Status = SnapshotStatus::kIoError; + result.Message = + "could not update snapshot file: " + error.message(); + return result; + } + result.Status = SnapshotStatus::kUpdated; + result.Message = "snapshot updated"; + return result; + } + + result.Status = SnapshotStatus::kMismatched; + result.Message = "snapshot differs at line " + + std::to_string( result.DifferenceLine ) + ", column " + + std::to_string( result.DifferenceColumn ); + return result; + } + +} // namespace Progmasoft::Catch3 diff --git a/src/Progmasoft/XmlWriter.cpp b/src/Progmasoft/XmlWriter.cpp new file mode 100644 index 00000000..b3a5f9b1 --- /dev/null +++ b/src/Progmasoft/XmlWriter.cpp @@ -0,0 +1,274 @@ +// Copyright (c) 2026 Progmasoft. +// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 +#include <Progmasoft/Catch3/Results.hpp> +#include <Progmasoft/Catch3/XmlWriter.hpp> + +#include <cmath> +#include <iomanip> +#include <locale> +#include <ostream> +#include <sstream> +#include <stdexcept> +#include <string_view> + +namespace Progmasoft::Catch3 { + + namespace { + + struct DecodedCharacter final { + char32_t Value = 0; + std::size_t Length = 0; + }; + + DecodedCharacter DecodeUtf8( std::string_view text, + std::size_t offset ) noexcept { + const auto first = static_cast<unsigned char>( text[offset] ); + if ( first < 0x80 ) { return { first, 1 }; } + + std::size_t length = 0; + char32_t value = 0; + char32_t minimum = 0; + if ( first >= 0xC2 && first <= 0xDF ) { + length = 2; + value = first & 0x1F; + minimum = 0x80; + } else if ( first >= 0xE0 && first <= 0xEF ) { + length = 3; + value = first & 0x0F; + minimum = 0x800; + } else if ( first >= 0xF0 && first <= 0xF4 ) { + length = 4; + value = first & 0x07; + minimum = 0x10000; + } else { + return { 0xFFFD, 0 }; + } + + if ( offset + length > text.size() ) { return { 0xFFFD, 0 }; } + for ( std::size_t index = 1; index < length; ++index ) { + const auto continuation = + static_cast<unsigned char>( text[offset + index] ); + if ( ( continuation & 0xC0 ) != 0x80 ) { return { 0xFFFD, 0 }; } + value = static_cast<char32_t>( ( value << 6 ) | + ( continuation & 0x3F ) ); + } + if ( value < minimum || value > 0x10FFFF || + ( value >= 0xD800 && value <= 0xDFFF ) ) { + return { 0xFFFD, 0 }; + } + return { value, length }; + } + + bool IsXmlCharacter( char32_t value ) noexcept { + return value == 0x09 || value == 0x0A || value == 0x0D || + ( value >= 0x20 && value <= 0xD7FF ) || + ( value >= 0xE000 && value <= 0xFFFD ) || + ( value >= 0x10000 && value <= 0x10FFFF ); + } + + void WriteEscaped( std::string_view value, + std::ostream& output, + bool attribute ) { + for ( std::size_t index = 0; index < value.size(); ) { + const DecodedCharacter character = DecodeUtf8( value, index ); + if ( character.Length == 0 || + !IsXmlCharacter( character.Value ) ) { + // Preserve parseability without silently dropping input. + output << "�"; + ++index; + continue; + } + + if ( character.Value < 0x80 ) { + switch ( static_cast<char>( character.Value ) ) { + case '&': + output << "&"; + break; + case '<': + output << "<"; + break; + case '>': + output << ">"; + break; + case '"': + if ( attribute ) { + output << """; + } else { + output.put( '"' ); + } + break; + case '\'': + if ( attribute ) { + output << "'"; + } else { + output.put( '\'' ); + } + break; + default: + output.put( static_cast<char>( character.Value ) ); + } + } else { + output.write( + value.data() + index, + static_cast<std::streamsize>( character.Length ) ); + } + index += character.Length; + } + } + + void WriteAttribute( std::ostream& output, + std::string_view name, + std::string_view value ) { + output.put( ' ' ); + output.write( name.data(), + static_cast<std::streamsize>( name.size() ) ); + output << "=\""; + WriteEscaped( value, output, true ); + output.put( '"' ); + } + + std::string FormatSeconds( double seconds ) { + std::ostringstream formatted; + formatted.imbue( std::locale::classic() ); + formatted << std::fixed << std::setprecision( 6 ) << seconds; + return formatted.str(); + } + + void WriteIndent( std::ostream& output, unsigned int level ) { + for ( unsigned int index = 0; index < level; ++index ) { + output << " "; + } + } + + void WriteTextElement( std::ostream& output, + unsigned int indent, + std::string_view name, + std::string_view value ) { + WriteIndent( output, indent ); + output.put( '<' ); + output.write( name.data(), + static_cast<std::streamsize>( name.size() ) ); + output.put( '>' ); + WriteEscaped( value, output, false ); + output << "</"; + output.write( name.data(), + static_cast<std::streamsize>( name.size() ) ); + output << ">\n"; + } + + TestRunSummary SummarizeSuite( const TestSuiteResult& suite ) { + return SummarizeTestSuite( suite ); + } + + void WriteTestCase( const TestCaseResult& testCase, + std::string_view suiteName, + std::ostream& output ) { + const bool hasBody = testCase.Status != TestCaseStatus::kPassed || + !testCase.StandardOutput.empty() || + !testCase.StandardError.empty(); + WriteIndent( output, 2 ); + output << "<testcase"; + WriteAttribute( output, + "classname", + testCase.ClassName.empty() ? suiteName + : testCase.ClassName ); + WriteAttribute( output, "name", testCase.Name ); + WriteAttribute( + output, "time", FormatSeconds( testCase.DurationSeconds ) ); + if ( !testCase.SourceFile.empty() ) { + WriteAttribute( output, "file", testCase.SourceFile ); + } + if ( !hasBody ) { + output << "/>\n"; + return; + } + + output << ">\n"; + if ( testCase.Status == TestCaseStatus::kFailed || + testCase.Status == TestCaseStatus::kErrored ) { + const bool isError = + testCase.Status == TestCaseStatus::kErrored; + WriteIndent( output, 3 ); + output << ( isError ? "<error" : "<failure" ); + WriteAttribute( output, "message", testCase.Message ); + WriteAttribute( output, + "type", + isError ? "TestError" : "AssertionFailure" ); + output.put( '>' ); + WriteEscaped( testCase.Details.empty() ? testCase.Message + : testCase.Details, + output, + false ); + output << ( isError ? "</error>\n" : "</failure>\n" ); + } else if ( testCase.Status == TestCaseStatus::kSkipped ) { + WriteIndent( output, 3 ); + output << "<skipped"; + WriteAttribute( output, "message", testCase.Message ); + output << "/>\n"; + } + if ( !testCase.StandardOutput.empty() ) { + WriteTextElement( + output, 3, "system-out", testCase.StandardOutput ); + } + if ( !testCase.StandardError.empty() ) { + WriteTextElement( + output, 3, "system-err", testCase.StandardError ); + } + WriteIndent( output, 2 ); + output << "</testcase>\n"; + } + + } // namespace + + void JUnitXmlWriter::Write( const TestRunResult& result, + std::ostream& output ) { + const TestRunSummary runSummary = SummarizeTestRun( result ); + output << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuites"; + WriteAttribute( output, "name", result.Name ); + WriteAttribute( output, "tests", std::to_string( runSummary.Tests ) ); + WriteAttribute( + output, "failures", std::to_string( runSummary.Failures ) ); + WriteAttribute( output, "errors", std::to_string( runSummary.Errors ) ); + WriteAttribute( + output, "skipped", std::to_string( runSummary.Skipped ) ); + WriteAttribute( + output, "time", FormatSeconds( runSummary.DurationSeconds ) ); + output << ">\n"; + + for ( const TestSuiteResult& suite : result.TestSuites ) { + const TestRunSummary suiteSummary = SummarizeSuite( suite ); + WriteIndent( output, 1 ); + output << "<testsuite"; + WriteAttribute( output, "name", suite.Name ); + WriteAttribute( + output, "tests", std::to_string( suiteSummary.Tests ) ); + WriteAttribute( + output, "failures", std::to_string( suiteSummary.Failures ) ); + WriteAttribute( + output, "errors", std::to_string( suiteSummary.Errors ) ); + WriteAttribute( + output, "skipped", std::to_string( suiteSummary.Skipped ) ); + WriteAttribute( + output, "time", FormatSeconds( suiteSummary.DurationSeconds ) ); + output << ">\n"; + + for ( const TestCaseResult& testCase : suite.TestCases ) { + WriteTestCase( testCase, suite.Name, output ); + } + WriteIndent( output, 1 ); + output << "</testsuite>\n"; + } + output << "</testsuites>\n"; + + if ( !output ) { + throw std::ios_base::failure( "could not write JUnit XML results" ); + } + } + + std::string JUnitXmlWriter::ToString( const TestRunResult& result ) { + std::ostringstream output; + Write( result, output ); + return output.str(); + } + +} // namespace Progmasoft::Catch3 |