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

summaryrefslogtreecommitdiffhomepage
path: root/src/Progmasoft/Runner/Execution.cpp
diff options
context:
space:
mode:
authorLeitwolf2026-10-24 19:03:40 +0300
committerGitHub2026-10-24 19:03:40 +0300
commit4f1c759cffcb14bd6511e1d7c5fbace682e24f2d (patch)
tree415fe5d48a5b48650bba3747beaf245d727c4f61 /src/Progmasoft/Runner/Execution.cpp
parent83c6fc3ac13abe83fa39f7b5f8559ccaf4412233 (diff)
parent622188f9c08e4af35941c146979883ed4882410e (diff)
downloadcatch3-4f1c759cffcb14bd6511e1d7c5fbace682e24f2d.tar.gz
Merge pull request #15 from Progmasoft/feature/catch3-runner
Replace Catch2's runner with the Catch3 runner
Diffstat (limited to 'src/Progmasoft/Runner/Execution.cpp')
-rw-r--r--src/Progmasoft/Runner/Execution.cpp599
1 files changed, 599 insertions, 0 deletions
diff --git a/src/Progmasoft/Runner/Execution.cpp b/src/Progmasoft/Runner/Execution.cpp
new file mode 100644
index 00000000..3a2206d0
--- /dev/null
+++ b/src/Progmasoft/Runner/Execution.cpp
@@ -0,0 +1,599 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <atomic>
+#include <catch2/catch_assertion_result.hpp>
+#include <catch2/catch_config.hpp>
+#include <catch2/catch_test_case_info.hpp>
+#include <catch2/interfaces/catch_interfaces_registry_hub.hpp>
+#include <catch2/interfaces/catch_interfaces_reporter.hpp>
+#include <catch2/interfaces/catch_interfaces_reporter_factory.hpp>
+#include <catch2/internal/catch_context.hpp>
+#include <catch2/internal/catch_reporter_registry.hpp>
+#include <catch2/internal/catch_run_context.hpp>
+#include <catch2/internal/catch_stdstreams.hpp>
+#include <catch2/internal/catch_string_manip.hpp>
+#include <catch2/reporters/catch_reporter_multi.hpp>
+#include <chrono>
+#include <cstdio>
+#include <cstdlib>
+#include <mutex>
+#include <optional>
+#include <sstream>
+
+#include "Execution.hpp"
+#include "Process.hpp"
+#include "Protocol.hpp"
+#include "ResultsJson.hpp"
+#include "TemporaryDirectory.hpp"
+
+namespace Progmasoft::Catch3::Runner
+{
+ namespace
+ {
+ using Clock = std::chrono::steady_clock;
+
+ std::int64_t
+ MicrosecondsBetween(Clock::time_point start, Clock::time_point end)
+ {
+ return std::chrono::duration_cast<std::chrono::microseconds>(
+ end - start)
+ .count();
+ }
+
+ FailureCategory
+ CategoryOf(Catch::ResultWas::OfType type)
+ {
+ switch (type)
+ {
+ case Catch::ResultWas::ExpressionFailed:
+ return FailureCategory::kAssertion;
+ case Catch::ResultWas::ExplicitFailure:
+ return FailureCategory::kExplicitFailure;
+ case Catch::ResultWas::ThrewException:
+ case Catch::ResultWas::Exception:
+ return FailureCategory::kUnexpectedException;
+ case Catch::ResultWas::DidntThrowException:
+ return FailureCategory::kMissingException;
+ case Catch::ResultWas::FatalErrorCondition:
+ return FailureCategory::kCrash;
+ default:
+ return FailureCategory::kNone;
+ }
+ }
+
+ std::string
+ Location(const Catch::SourceLineInfo &info)
+ {
+ return std::string{ info.file } + ":" + std::to_string(info.line);
+ }
+
+ /// Turns the events of Catch2's engine into protocol messages.
+ class ProtocolListener final: public Catch::IEventListener
+ {
+ public:
+ ProtocolListener(const Catch::IConfig *config,
+ EventSink sink,
+ bool includeSuccessful)
+ : IEventListener{ config }
+ , sink_{ std::move(sink) }
+ , includeSuccessful_{ includeSuccessful }
+ {
+ // Captured output belongs to the test that printed it.
+ m_preferences.shouldRedirectStdOut = true;
+ m_preferences.shouldReportAllAssertions = includeSuccessful;
+ // The start of every assertion, for the location of a crash
+ // or a hang.
+ m_preferences.shouldReportAllAssertionStarts = true;
+ }
+
+ void
+ Begin(std::size_t index, std::size_t attempt)
+ {
+ index_ = index;
+ attempt_ = attempt;
+ depth_ = 0;
+ sections_.clear();
+ sectionStarts_.clear();
+ firstCategory_ = FailureCategory::kNone;
+ lastLocation_.clear();
+ lastSentLocation_ = Clock::time_point{};
+ }
+
+ void
+ noMatchingTestCases(Catch::StringRef) override
+ {}
+
+ void
+ reportInvalidTestSpec(Catch::StringRef) override
+ {}
+
+ void
+ testRunStarting(const Catch::TestRunInfo &) override
+ {}
+
+ void
+ testCaseStarting(const Catch::TestCaseInfo &) override
+ {
+ start_ = Clock::now();
+ JsonValue message = Message("start");
+ message.Set("index", index_);
+ message.Set("attempt", attempt_);
+ sink_(message);
+ }
+
+ void
+ testCasePartialStarting(const Catch::TestCaseInfo &,
+ std::uint64_t) override
+ {
+ // Every run through the test case enters the sections
+ // again; depth starts over.
+ depth_ = 0;
+ sections_.clear();
+ sectionStarts_.clear();
+ }
+
+ void
+ sectionStarting(const Catch::SectionInfo &info) override
+ {
+ // Catch2 reports the test case itself as the outermost
+ // section; the runner counts real sections only.
+ if (depth_++ == 0)
+ {
+ return;
+ }
+ sections_.push_back(info.name);
+ sectionStarts_.push_back(Clock::now());
+ JsonValue message = Message("section");
+ message.Set("begin", true);
+ message.Set("name", info.name);
+ message.Set("file", std::string{ info.lineInfo.file });
+ message.Set("line", info.lineInfo.line);
+ message.Set("depth", sections_.size() - 1);
+ message.Set("at", MicrosecondsBetween(start_, Clock::now()));
+ sink_(message);
+ }
+
+ void
+ sectionEnded(const Catch::SectionStats &stats) override
+ {
+ if (stats.missingAssertions)
+ {
+ // --warn=no-assertions: say why the test failed.
+ AssertionRecord record;
+ record.file
+ = std::string{ stats.sectionInfo.lineInfo.file };
+ record.line = stats.sectionInfo.lineInfo.line;
+ record.message = "no assertion ran in "
+ + stats.sectionInfo.name
+ + " (--warn=no-assertions)";
+ record.sections = sections_;
+ record.category = FailureCategory::kAssertion;
+ if (firstCategory_ == FailureCategory::kNone)
+ {
+ firstCategory_ = record.category;
+ }
+ JsonValue message = ToJson(record);
+ message.Set("t", "assertion");
+ sink_(message);
+ }
+ if (depth_ == 0)
+ {
+ return;
+ }
+ if (--depth_ == 0 || sections_.empty())
+ {
+ return;
+ }
+ const auto now = Clock::now();
+ JsonValue message = Message("section");
+ message.Set("begin", false);
+ message.Set("depth", sections_.size() - 1);
+ message.Set("at", MicrosecondsBetween(start_, now));
+ message.Set("duration",
+ MicrosecondsBetween(sectionStarts_.back(), now));
+ sink_(message);
+ sections_.pop_back();
+ sectionStarts_.pop_back();
+ }
+
+ void
+ assertionStarting(const Catch::AssertionInfo &info) override
+ {
+ lastLocation_ = Location(info.lineInfo);
+ // A hang is only noticed from outside, so the location goes
+ // out now and then rather than with every assertion.
+ const auto now = Clock::now();
+ if (now - lastSentLocation_ > std::chrono::milliseconds(200))
+ {
+ lastSentLocation_ = now;
+ JsonValue message = Message("at");
+ message.Set("location", lastLocation_);
+ sink_(message);
+ }
+ }
+
+ void
+ assertionEnded(const Catch::AssertionStats &stats) override
+ {
+ const auto &result = stats.assertionResult;
+ const bool skipped
+ = result.getResultType() == Catch::ResultWas::ExplicitSkip;
+ const bool passed = result.succeeded() || skipped;
+ if (passed && !includeSuccessful_ && !skipped)
+ {
+ return;
+ }
+ AssertionRecord record;
+ record.file = std::string{ result.getSourceInfo().file };
+ record.line = result.getSourceInfo().line;
+ record.macro = std::string{ result.getTestMacroName() };
+ if (result.hasExpression())
+ {
+ record.expression = result.getExpression();
+ record.expansion = result.getExpandedExpression();
+ }
+ record.message = std::string{ result.getMessage() };
+ for (const auto &info : stats.infoMessages)
+ {
+ if (info.type == Catch::ResultWas::Info
+ || info.type == Catch::ResultWas::Warning)
+ {
+ record.context.push_back(info.message);
+ }
+ }
+ record.sections = sections_;
+ record.passed = passed;
+ record.category = skipped ? FailureCategory::kNone
+ : CategoryOf(result.getResultType());
+ record.allowed = !passed && result.isOk();
+ if (!passed && !record.allowed
+ && firstCategory_ == FailureCategory::kNone)
+ {
+ firstCategory_ = record.category;
+ }
+ JsonValue message = ToJson(record);
+ message.Set("t", "assertion");
+ sink_(message);
+ }
+
+ void
+ benchmarkPreparing(Catch::StringRef) override
+ {}
+
+ void
+ benchmarkStarting(const Catch::BenchmarkInfo &) override
+ {}
+
+ void
+ benchmarkEnded(const Catch::BenchmarkStats<> &stats) override
+ {
+ BenchmarkRecord record;
+ record.name = stats.info.name;
+ record.samples = stats.samples.size();
+ record.iterations
+ = static_cast<std::size_t>(stats.info.iterations);
+ record.meanNanoseconds = stats.mean.point.count();
+ record.lowMeanNanoseconds = stats.mean.lower_bound.count();
+ record.highMeanNanoseconds = stats.mean.upper_bound.count();
+ record.standardDeviationNanoseconds
+ = stats.standardDeviation.point.count();
+ record.outlierVariance = stats.outlierVariance;
+ JsonValue message = ToJson(record);
+ message.Set("t", "benchmark");
+ sink_(message);
+ }
+
+ void
+ benchmarkFailed(Catch::StringRef) override
+ {}
+
+ void
+ testCasePartialEnded(const Catch::TestCaseStats &,
+ std::uint64_t) override
+ {}
+
+ void
+ testCaseEnded(const Catch::TestCaseStats &stats) override
+ {
+ const auto &counts = stats.totals.testCases;
+ Outcome outcome = Outcome::kPassed;
+ if (counts.failed > 0)
+ {
+ outcome = Outcome::kFailed;
+ }
+ else if (counts.failedButOk > 0)
+ {
+ outcome = Outcome::kFailedAsExpected;
+ }
+ else if (counts.skipped > 0)
+ {
+ outcome = Outcome::kSkipped;
+ }
+ FailureCategory category = firstCategory_;
+ if (outcome == Outcome::kFailed
+ && category == FailureCategory::kNone)
+ {
+ // A [!shouldfail] test that passed, or a test without
+ // assertions under --warn=no-assertions.
+ category = FailureCategory::kAssertion;
+ }
+ JsonValue message = Message("end");
+ message.Set("index", index_);
+ message.Set("attempt", attempt_);
+ message.Set("outcome", OutcomeName(outcome));
+ if (outcome == Outcome::kFailed)
+ {
+ message.Set("category", CategoryName(category));
+ }
+ message.Set("passed", stats.totals.assertions.passed);
+ // Failures [!mayfail] and [!shouldfail] allow are failures
+ // too; the verdict says whether they count.
+ message.Set("failed",
+ stats.totals.assertions.failed
+ + stats.totals.assertions.failedButOk);
+ message.Set("duration",
+ MicrosecondsBetween(start_, Clock::now()));
+ if (!stats.stdOut.empty())
+ {
+ message.Set("stdout", stats.stdOut);
+ }
+ if (!stats.stdErr.empty())
+ {
+ message.Set("stderr", stats.stdErr);
+ }
+ sink_(message);
+ }
+
+ void
+ testRunEnded(const Catch::TestRunStats &) override
+ {}
+
+ void
+ fatalErrorEncountered(Catch::StringRef error) override
+ {
+ JsonValue message = Message("fatal");
+ message.Set("message", std::string{ error });
+ message.Set("location", lastLocation_);
+ sink_(message);
+ }
+
+ private:
+ EventSink sink_;
+ bool includeSuccessful_;
+ std::size_t index_ = 0;
+ std::size_t attempt_ = 0;
+ std::size_t depth_ = 0;
+ std::vector<std::string> sections_;
+ std::vector<Clock::time_point> sectionStarts_;
+ Clock::time_point start_{};
+ FailureCategory firstCategory_ = FailureCategory::kNone;
+ std::string lastLocation_;
+ Clock::time_point lastSentLocation_{};
+ };
+ } // namespace
+
+ Catch::ConfigData
+ ToConfigData(const Options &options, const std::string &programName)
+ {
+ Catch::ConfigData data;
+ data.showSuccessfulTests = options.includeSuccessful;
+ data.shouldDebugBreak = options.breakIntoDebugger;
+ data.noThrow = options.noThrow;
+ data.filenamesAsTags = options.filenamesAsTags;
+ data.allowZeroTests = options.allowNoTests;
+ data.rngSeed = options.seed;
+ data.rngSeedWasFixed = true;
+ data.skipBenchmarks = options.skipBenchmarks;
+ data.benchmarkNoAnalysis = options.benchmarkNoAnalysis;
+ data.benchmarkSamples = options.benchmarkSamples;
+ data.benchmarkResamples = options.benchmarkResamples;
+ data.benchmarkConfidenceInterval = options.benchmarkConfidenceInterval;
+ data.benchmarkWarmupTime = options.benchmarkWarmupTime.count();
+ int warnings = Catch::WarnAbout::Nothing;
+ if (options.warnNoAssertions)
+ {
+ warnings |= Catch::WarnAbout::NoAssertions;
+ }
+ if (options.warnUnmatchedSpecs)
+ {
+ warnings |= Catch::WarnAbout::UnmatchedTestSpec;
+ }
+ if (options.warnInfiniteGenerators)
+ {
+ warnings |= Catch::WarnAbout::InfiniteGenerator;
+ }
+ data.warnings = static_cast<Catch::WarnAbout::What>(warnings);
+ data.runOrder = Catch::TestRunOrder::Declared;
+ data.processName = programName;
+ data.testsOrTags = options.testSpecs;
+ for (const auto &section : options.sections)
+ {
+ data.pathFilters.emplace_back(Catch::PathFilter::For::Section,
+ Catch::trim(section));
+ }
+ for (const auto &path : options.paths)
+ {
+ data.useNewPathFilteringBehaviour = true;
+ data.pathFilters.emplace_back(
+ path[0] == 'c' ? Catch::PathFilter::For::Section
+ : Catch::PathFilter::For::Generator,
+ Catch::trim(path.substr(2)));
+ }
+ return data;
+ }
+
+ namespace
+ {
+ /// The executor whose test is running, for the exit handler.
+ std::atomic<TestExecutor::State *> runningState{ nullptr };
+
+ /// A test that calls `exit` never ends, and what Catch captured of
+ /// its output would be lost with the process: it is written where
+ /// the runner reads the process's own output.
+ void
+ WriteCapturedOutputAtExit();
+ } // namespace
+
+ struct TestExecutor::State final
+ {
+ Catch::Config config;
+ const Catalog &catalog;
+ ProtocolListener *listener = nullptr;
+ std::optional<Catch::RunContext> context;
+
+ State(const Catch::ConfigData &data, const Catalog &knownTests)
+ : config{ data }
+ , catalog{ knownTests }
+ {}
+ };
+
+ TestExecutor::TestExecutor(const Options &options,
+ const Catalog &catalog,
+ const std::string &programName,
+ EventSink sink)
+ : state_{ std::make_unique<State>(ToConfigData(options, programName),
+ catalog) }
+ {
+ Catch::getCurrentMutableContext().setConfig(&state_->config);
+ auto multi
+ = Catch::Detail::make_unique<Catch::MultiReporter>(&state_->config);
+ for (const auto &factory :
+ Catch::getRegistryHub().getReporterRegistry().getListeners())
+ {
+ multi->addListener(factory->create(&state_->config));
+ }
+ auto listener = Catch::Detail::make_unique<ProtocolListener>(
+ &state_->config,
+ std::move(sink),
+ options.includeSuccessful);
+ state_->listener = listener.get();
+ multi->addReporter(CATCH_MOVE(listener));
+ Catch::seedRng(state_->config);
+ state_->context.emplace(&state_->config, CATCH_MOVE(multi));
+ static const bool registered
+ = std::atexit(&WriteCapturedOutputAtExit) == 0;
+ static_cast<void>(registered);
+ }
+
+ TestExecutor::~TestExecutor()
+ {
+ // The run context reports the end of the run to the listeners.
+ state_->context.reset();
+ Catch::getCurrentMutableContext().setConfig(nullptr);
+ }
+
+ void
+ TestExecutor::Run(std::size_t index,
+ std::size_t attempt,
+ const std::string &temporaryDirectory)
+ {
+ if (index >= state_->catalog.Handles().size())
+ {
+ return;
+ }
+ const TemporaryDirectoryScope scope{ temporaryDirectory };
+ state_->listener->Begin(index, attempt);
+ runningState.store(state_.get());
+ static_cast<void>(
+ state_->context->runTest(state_->catalog.Handles()[index]));
+ runningState.store(nullptr);
+ }
+
+ namespace
+ {
+ void
+ WriteCapturedOutputAtExit()
+ {
+ TestExecutor::State *state = runningState.exchange(nullptr);
+ if (state == nullptr || !state->context)
+ {
+ return;
+ }
+ const std::string output = state->context->endCapture();
+ std::fflush(stdout);
+ std::fflush(stderr);
+ WriteStandardError(output);
+ }
+ } // namespace
+
+ int
+ RunWorker(const Options &options, const std::string &programName)
+ {
+ QuietCrashReporting();
+ auto streams = TakeStandardStreams();
+ if (!streams)
+ {
+ std::fprintf(stderr,
+ "catch3 worker: %s\n",
+ streams.GetError().Message().c_str());
+ return 1;
+ }
+ auto catalog = Catalog::Load();
+ if (!catalog)
+ {
+ std::fprintf(stderr,
+ "catch3 worker: %s\n",
+ catalog.GetError().Message().c_str());
+ return 1;
+ }
+ MessageWriter writer{ streams->events };
+ bool connected = true;
+ const auto send = [&](const JsonValue &message) {
+ if (connected && !writer.Send(message))
+ {
+ // The runner is gone; there is no one to report to.
+ connected = false;
+ }
+ };
+ // Listeners are created, and told of the run, before the greeting.
+ // What they print then travels in the greeting: through the error
+ // stream it could arrive after the runner gave this worker a test,
+ // and pass for that test's output.
+ std::ostringstream startup;
+ auto *const previousOut = Catch::cout().rdbuf(startup.rdbuf());
+ auto *const previousErr = Catch::cerr().rdbuf(startup.rdbuf());
+ auto *const previousLog = Catch::clog().rdbuf(startup.rdbuf());
+ TestExecutor executor{ options, *catalog, programName, send };
+ Catch::cout().rdbuf(previousOut);
+ Catch::cerr().rdbuf(previousErr);
+ Catch::clog().rdbuf(previousLog);
+ std::fflush(stdout);
+ std::fflush(stderr);
+
+ JsonValue hello = Message("hello");
+ hello.Set("output", startup.str());
+ hello.Set("pid", CurrentProcessId());
+ hello.Set("identity", catalog->Identity());
+ hello.Set("tests", catalog->Tests().size());
+ hello.Set("protocol", kWorkerProtocolVersion);
+ send(hello);
+ LineReader commands{ streams->commands };
+ while (connected)
+ {
+ const auto line = commands.Next();
+ if (!line)
+ {
+ break;
+ }
+ const auto command = ParseJson(*line);
+ if (!command)
+ {
+ continue;
+ }
+ const std::string &kind = (*command)["t"].AsString();
+ if (kind == "quit")
+ {
+ break;
+ }
+ if (kind == "run")
+ {
+ executor.Run(
+ static_cast<std::size_t>((*command)["index"].AsUnsigned()),
+ static_cast<std::size_t>(
+ (*command)["attempt"].AsUnsigned()),
+ (*command)["temp"].AsString());
+ }
+ }
+ return 0;
+ }
+} // namespace Progmasoft::Catch3::Runner