diff --git a/.clang-tidy b/.clang-tidy index 73ff6a07..83b2c607 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -30,4 +30,17 @@ HeaderFilterRegex: '.*' FormatStyle: file CheckOptions: AllowSoleDefaultDtor: true + # readability-identifier-naming + readability-identifier-naming.NamespaceCase: lower_case + readability-identifier-naming.StructCase: CamelCase + readability-identifier-naming.ClassCase: CamelCase + readability-identifier-naming.EnumCase: CamelCase + readability-identifier-naming.EnumConstantCase: camelBack + readability-identifier-naming.MemberCase: camelBack + readability-identifier-naming.ParameterCase: camelBack + readability-identifier-naming.VariableCase: camelBack + readability-identifier-naming.FunctionCase: CamelCase + readability-identifier-naming.MethodCase: CamelCase + readability-identifier-naming.TypeAliasCase: CamelCase + readability-identifier-naming.TypedefCase: CamelCase ... diff --git a/.vscode/settings.json b/.vscode/settings.json index 1d82165e..fdff978e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,8 @@ "cmake.useCMakePresets": "always", "cucumberautocomplete.steps": [ "compatibility/*/*.cpp", - "cucumber_cpp/example/steps/*.cpp", - "cucumber_cpp/acceptance_test/steps/*.cpp" + "cucumber_cpp/example/*/*.cpp", + "cucumber_cpp/acceptance_test/*/*.cpp" ], "cucumberautocomplete.gherkinDefinitionPart": "(GIVEN|WHEN|THEN|STEP)\\(", "cucumberautocomplete.customParameters": [ diff --git a/CMakeLists.txt b/CMakeLists.txt index cbd60866..aa268b4a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,19 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED On) set(CMAKE_CXX_EXTENSIONS Off) +# Compiler cache +find_program(CCACHE_PROGRAM ccache) +if(CCACHE_PROGRAM) + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + message(STATUS "Using ccache: ${CCACHE_PROGRAM}") +else() + find_program(SCCACHE_PROGRAM sccache) + if(SCCACHE_PROGRAM) + set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") + message(STATUS "Using sccache: ${SCCACHE_PROGRAM}") + endif() +endif() + set_directory_properties(PROPERTY USE_FOLDERS ON) include(FetchContent) diff --git a/CMakePresets.json b/CMakePresets.json index 4e9f5dca..ea3d2ed6 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -13,9 +13,7 @@ "installDir": "${sourceDir}/.install/${presetName}", "cacheVariables": { "CMAKE_EXPORT_COMPILE_COMMANDS": "On", - "CMAKE_CONFIGURATION_TYPES": "Debug;RelWithDebInfo;Release", - "CMAKE_C_COMPILER_LAUNCHER": "ccache", - "CMAKE_CXX_COMPILER_LAUNCHER": "ccache" + "CMAKE_CONFIGURATION_TYPES": "Debug;RelWithDebInfo;Release" } }, { @@ -194,7 +192,7 @@ }, "execution": { "noTestsAction": "error", - "stopOnFailure": true + "stopOnFailure": false } }, { diff --git a/cmake/ccr_add_plugin.cmake b/cmake/ccr_add_plugin.cmake new file mode 100644 index 00000000..8f81ea2e --- /dev/null +++ b/cmake/ccr_add_plugin.cmake @@ -0,0 +1,64 @@ +# Helper function for creating cucumber-cpp-runner plugin targets +# +# Usage: +# ccr_add_plugin(my_plugin) +# ccr_add_plugin(my_plugin step1.cpp step2.cpp hooks.cpp) +# +# Creates a MODULE library target suitable for dynamic loading via --load. +# Plugins use the same STEP/HOOK/PARAMETER macros as statically-linked code. +# All symbols resolve from the host executable at runtime (ENABLE_EXPORTS). +# On macOS, -undefined dynamic_lookup suppresses linker errors for host symbols. +# On Windows, the plugin links against the host executable's import library. +# +# The default ccr_register entry point is provided automatically via +# ccr_plugin_register. Plugins do not need to define it themselves. +# +# Sources can be passed directly (like add_library) or added later via +# target_sources. + +# Object library providing the default ccr_register entry point. +# Built once and linked into every plugin created by ccr_add_plugin. +if (NOT TARGET ccr_plugin_register) + add_library(ccr_plugin_register OBJECT + ${CMAKE_CURRENT_LIST_DIR}/../cucumber_cpp/library/plugin/PluginRegister.cpp + ) + target_include_directories(ccr_plugin_register SYSTEM PRIVATE + $ + $ + ) + target_link_libraries(ccr_plugin_register PRIVATE fmt::fmt) + set_target_properties(ccr_plugin_register PROPERTIES + CXX_VISIBILITY_PRESET default + ) +endif() + +function(ccr_add_plugin TARGET_NAME) + add_library(${TARGET_NAME} MODULE ${ARGN} $) + + # Plugins need the header include paths from cucumber_cpp and its + # transitive dependencies (fmt, gtest, gherkin, etc.) but must NOT link + # the actual libraries — symbols resolve from the host at runtime. + target_include_directories(${TARGET_NAME} SYSTEM PRIVATE + $ + $ + ) + + target_compile_definitions(${TARGET_NAME} PRIVATE + $ + ) + + if (WIN32) + target_link_libraries(${TARGET_NAME} PRIVATE cucumber_cpp) + elseif (APPLE) + target_link_options(${TARGET_NAME} PRIVATE -undefined dynamic_lookup) + endif() + + set_target_properties(${TARGET_NAME} PROPERTIES + PREFIX "" + CXX_VISIBILITY_PRESET default + ) + + if (APPLE) + set_target_properties(${TARGET_NAME} PROPERTIES SUFFIX ".dylib") + endif() +endfunction() diff --git a/cmake/dependencies.cpm.cmake b/cmake/dependencies.cpm.cmake index fc6369fa..d5ae3de2 100644 --- a/cmake/dependencies.cpm.cmake +++ b/cmake/dependencies.cpm.cmake @@ -106,6 +106,8 @@ if(CCR_FETCH_DEPS) set_target_properties(gtest gtest_main gmock gmock_main PROPERTIES FOLDER External/GoogleTest ) + target_compile_options(gtest PRIVATE $<$:-Wno-character-conversion>) + target_compile_options(gmock PRIVATE $<$:-Wno-character-conversion>) endif() # --------------------------------------------------------------------------- diff --git a/compatibility/BaseCompatibility.cpp b/compatibility/BaseCompatibility.cpp index fb1da6e8..2c89dcc5 100644 --- a/compatibility/BaseCompatibility.cpp +++ b/compatibility/BaseCompatibility.cpp @@ -1,38 +1,27 @@ #include "BaseCompatibility.hpp" -#include "cucumber/messages/envelope.hpp" #include "cucumber_cpp/Steps.hpp" -#include "cucumber_cpp/library/api/Formatters.hpp" -#include "cucumber_cpp/library/api/RunCucumber.hpp" -#include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" -#include "cucumber_cpp/library/support/DefinitionRegistration.hpp" -#include "cucumber_cpp/library/support/Types.hpp" -#include "cucumber_cpp/library/tag_expression/Parser.hpp" -#include "cucumber_cpp/library/util/Broadcaster.hpp" +#include "cucumber_cpp/library/Application.hpp" #include "cucumber_cpp/library/util/Duration.hpp" #include "cucumber_cpp/library/util/Timestamp.hpp" #include "nlohmann/json.hpp" #include "nlohmann/json_fwd.hpp" +#include "gmock/gmock.h" +#include "gtest/gtest.h" #include #include #include -#include -#include -#include -#include -#include #include #include -#include #include -#include #include #include +#include namespace compatibility { namespace { - void RemoveIncompatibilities(nlohmann::json& json, const Devkit& devkit) + void RemoveIncompatibilities(nlohmann::json& json, const std::string& sourceDir) { for (auto jsonIter = json.begin(); jsonIter != json.end();) { @@ -43,19 +32,14 @@ namespace compatibility jsonIter = json.erase(jsonIter); else if (value.is_object()) { - RemoveIncompatibilities(value, devkit); + RemoveIncompatibilities(value, sourceDir); ++jsonIter; } else if (value.is_array()) { - for (auto valueIter = value.begin(); valueIter != value.end();) - { - if (valueIter->is_object()) - RemoveIncompatibilities(*valueIter, devkit); - - ++valueIter; - } - + for (auto& element : value) + if (element.is_object()) + RemoveIncompatibilities(element, sourceDir); ++jsonIter; } else if (key == "data") @@ -67,7 +51,7 @@ namespace compatibility { auto uri = value.get(); - uri = std::regex_replace(uri, std::regex(R"(samples\/[^\/]+)"), devkit.folder); + uri = std::regex_replace(uri, std::regex(R"(samples\/[^\/]+)"), sourceDir); uri = std::regex_replace(uri, std::regex(R"(\.ts$)"), ".cpp"); std::filesystem::path path{ uri }; @@ -83,105 +67,59 @@ namespace compatibility } } - struct BroadcastListener + std::list LoadNdjson(const std::filesystem::path& path) { - BroadcastListener(std::filesystem::path ndjsonin, std::filesystem::path expectedndjson, std::filesystem::path ndout, cucumber_cpp::library::util::Broadcaster& broadcaster) - : listener(broadcaster, [this](const cucumber::messages::envelope& envelope) - { - OnEvent(envelope); - }) - , ndjsonin{ std::move(ndjsonin) } - , expectedndjson{ std::move(expectedndjson) } - , actualndjson(std::move(ndout)) - { - while (!ifs.eof()) - { - std::string line; - std::getline(ifs, line); + std::list envelopes; + std::ifstream ifs{ path }; + std::string line; - if (line.empty()) - continue; + while (std::getline(ifs, line)) + { + if (line.empty()) + continue; - auto json = nlohmann::json::parse(line); + auto json = nlohmann::json::parse(line); - if (json.contains("meta")) - continue; + if (json.contains("meta")) + continue; - expectedEnvelopes.emplace_back(std::move(json)); - } + envelopes.emplace_back(std::move(json)); } - void OnEvent(const cucumber::messages::envelope& envelope) - { - nlohmann::json actualJson{}; - to_json(actualJson, envelope); + return envelopes; + } - actualEnvelopes.emplace_back(std::move(actualJson)); - } + void CompareEnvelopes(std::list& actual, std::list& expected, const std::string& sourceDir, const std::filesystem::path& kitDir) + { + for (auto& json : actual) + RemoveIncompatibilities(json, sourceDir); + for (auto& json : expected) + RemoveIncompatibilities(json, sourceDir); - void CompareEnvelopes(const Devkit& devkit) + // Write out normalized envelopes for debugging + std::filesystem::create_directories(kitDir); { - EXPECT_THAT(actualEnvelopes.size(), testing::Eq(expectedEnvelopes.size())); - - for (auto& json : actualEnvelopes) - { - RemoveIncompatibilities(json, devkit); - actualOfs << json.dump() << "\n"; - } - - for (auto& json : expectedEnvelopes) - { - RemoveIncompatibilities(json, devkit); - expectedOfs << json.dump() << "\n"; - } - - while (!actualEnvelopes.empty() && !expectedEnvelopes.empty()) - { - auto actualJson = actualEnvelopes.front(); - actualEnvelopes.pop_front(); - - auto expectedJson = expectedEnvelopes.front(); - expectedEnvelopes.pop_front(); - - EXPECT_THAT(actualJson, testing::Eq(expectedJson)); - } + std::ofstream ofs{ kitDir / "actual.ndjson" }; + for (const auto& json : actual) + ofs << json.dump() << "\n"; + } + { + std::ofstream ofs{ kitDir / "expected.ndjson" }; + for (const auto& json : expected) + ofs << json.dump() << "\n"; } - private: - cucumber_cpp::library::util::Listener listener; - std::filesystem::path ndjsonin; - std::ifstream ifs{ ndjsonin }; - - std::filesystem::path expectedndjson; - std::ofstream expectedOfs{ expectedndjson }; - - std::filesystem::path actualndjson; - std::ofstream actualOfs{ actualndjson }; - - std::list expectedEnvelopes; - std::list actualEnvelopes; - }; + EXPECT_THAT(actual.size(), testing::Eq(expected.size())); - bool IsFeatureFile(const std::filesystem::directory_entry& entry) - { - return std::filesystem::is_regular_file(entry) && entry.path().has_extension() && entry.path().extension() == ".feature"; - } + auto actualIter = actual.begin(); + auto expectedIter = expected.begin(); - std::set> GetFeatureFiles(const std::set>& paths) - { - std::set> files; - - for (const auto& feature : paths) - if (std::filesystem::is_directory(feature)) - for (const auto& entry : std::filesystem::directory_iterator{ feature } | std::views::filter(IsFeatureFile)) - { - std::cout << " found feature file: " << entry.path() << "\n"; - files.insert(entry.path()); - } - else - files.insert(feature); - - return files; + while (actualIter != actual.end() && expectedIter != expected.end()) + { + EXPECT_THAT(*actualIter, testing::Eq(*expectedIter)); + ++actualIter; + ++expectedIter; + } } struct StopwatchIncremental : cucumber_cpp::library::util::Stopwatch @@ -214,39 +152,44 @@ namespace compatibility }; } - void RunDevkit(Devkit devkit) + void RunDevkit(const KitInfo& kit) { - compatibility::StopwatchIncremental stopwatch; - compatibility::TimestampGeneratorIncremental timestampGenerator; - - devkit.paths = GetFeatureFiles(devkit.paths); - const auto isReversed = devkit.kitString.ends_with("-reversed"); - - cucumber_cpp::library::support::RunOptions runOptions{ - .sources = { - .paths = devkit.paths, - .tagExpression = cucumber_cpp::library::tag_expression::Parse(devkit.tagExpression), - .ordering = isReversed ? cucumber_cpp::library::support::RunOptions::Ordering::reverse : cucumber_cpp::library::support::RunOptions::Ordering::defined, - }, - .runtime = { - .retry = devkit.kitString.starts_with("retry") ? 2u : 0u, - .strict = true, - .retryTagExpression = cucumber_cpp::library::tag_expression::Parse(""), - }, - }; + const auto actualNdjsonPath = kit.sourceDir / "actual_run.ndjson"; - cucumber_cpp::library::cucumber_expression::ParameterRegistry parameterRegistry{ cucumber_cpp::library::support::DefinitionRegistration::Instance().GetRegisteredParameters() }; + // Build CLI args: program name, --load , --format message:, extra args, -- + std::vector argStrings; + argStrings.emplace_back("compatibility.test"); + argStrings.emplace_back("--load"); + argStrings.emplace_back(kit.pluginPath.string()); + argStrings.emplace_back("--format"); + argStrings.emplace_back("pretty"); + argStrings.emplace_back("message:" + actualNdjsonPath.string()); - auto contextStorageFactory{ std::make_shared() }; - auto programContext{ std::make_unique(contextStorageFactory) }; + for (const auto& arg : kit.extraArgs) + argStrings.emplace_back(arg); - cucumber_cpp::library::util::Broadcaster broadcaster; + argStrings.emplace_back("--no-recursive"); + argStrings.emplace_back(kit.sourceDir.string()); + + std::vector argv; + argv.reserve(argStrings.size()); + for (const auto& s : argStrings) + argv.push_back(s.c_str()); + + { + cucumber_cpp::library::Application app{ + std::make_shared(), false, + std::make_unique(), + std::make_unique() + }; + static_cast(app.Run(static_cast(argv.size()), argv.data())); + } - BroadcastListener broadcastListener{ devkit.ndjsonFile, devkit.ndjsonFile.parent_path() / "expected.ndjson", devkit.ndjsonFile.parent_path() / "actual.ndjson", broadcaster }; + auto actualEnvelopes = LoadNdjson(actualNdjsonPath); + auto expectedEnvelopes = LoadNdjson(kit.ndjsonFile); - cucumber_cpp::library::api::Formatters formatters; - cucumber_cpp::library::api::RunCucumber(runOptions, parameterRegistry, *programContext, broadcaster, formatters, { "junit", "message", "pretty", "summary", "usage" }, {}); + CompareEnvelopes(actualEnvelopes, expectedEnvelopes, kit.sourceDir.string(), kit.buildDir); - broadcastListener.CompareEnvelopes(devkit); + std::filesystem::remove(actualNdjsonPath); } } diff --git a/compatibility/BaseCompatibility.hpp b/compatibility/BaseCompatibility.hpp index b594aa3f..6006a0d0 100644 --- a/compatibility/BaseCompatibility.hpp +++ b/compatibility/BaseCompatibility.hpp @@ -1,27 +1,30 @@ #ifndef COMPATIBILITY_BASE_COMPATIBILITY_HPP #define COMPATIBILITY_BASE_COMPATIBILITY_HPP -#include #include -#include -#include -#include -#include +#include #include +#include namespace compatibility { - struct Devkit + struct KitInfo { - std::set> paths; - std::string tagExpression; - std::size_t retry; + std::string name; + std::filesystem::path sourceDir; + std::filesystem::path buildDir; std::filesystem::path ndjsonFile; - std::string folder; - std::string kitString; + std::filesystem::path pluginPath; + std::vector extraArgs; + bool hasPlugin; }; - void RunDevkit(Devkit devkit); + inline void PrintTo(const KitInfo& kit, std::ostream* os) + { + *os << kit.name; + } + + void RunDevkit(const KitInfo& kit); } #endif diff --git a/compatibility/CMakeLists.txt b/compatibility/CMakeLists.txt index b9372459..58e78605 100644 --- a/compatibility/CMakeLists.txt +++ b/compatibility/CMakeLists.txt @@ -1,3 +1,5 @@ +include(${PROJECT_SOURCE_DIR}/cmake/ccr_add_plugin.cmake) + add_library(compatibility.base) target_sources(compatibility.base PRIVATE BaseCompatibility.cpp @@ -18,49 +20,52 @@ target_compile_definitions(compatibility.base PRIVATE CCR_BINARY_DIR="${CCR_BINARY_DIR_CMAKE}" ) -function(add_compatibility_kit name) - set(libname compatibility.${name}.test) - add_executable(${libname}) - - if(CCR_BUILD_TESTS) - add_test(NAME ${libname} COMMAND ${libname}) +# Build a plugin for each kit that has a .cpp file +file(GLOB kits RELATIVE ${CMAKE_CURRENT_LIST_DIR} ${CMAKE_CURRENT_LIST_DIR}/*) +foreach(kit ${kits}) + if (IS_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/${kit} AND EXISTS ${CMAKE_CURRENT_LIST_DIR}/${kit}/${kit}.cpp) + ccr_add_plugin(compatibility.${kit}.plugin ${kit}/${kit}.cpp) endif() +endforeach() - target_link_libraries(${libname} PRIVATE - GTest::gtest_main - compatibility.base - ) - - target_include_directories(${libname} PUBLIC - $ - ) +# Single test executable that loads plugins at runtime +add_executable(compatibility.test) +target_sources(compatibility.test PRIVATE + TestCompatibility.cpp +) +target_link_libraries(compatibility.test PRIVATE + GTest::gtest_main + compatibility.base +) - target_sources(${libname} PRIVATE - TestCompatibility.cpp +# Plugins resolve symbols from this executable at runtime via dlopen. +# ENABLE_EXPORTS creates the dynamic symbol table, but the linker +# normally strips unreferenced symbols from static libraries. +# WHOLE_ARCHIVE ensures all cucumber_cpp symbols are linked and exported +# for plugin use. +if (NOT WIN32) + target_link_libraries(compatibility.test PRIVATE + $ ) +endif() - if (EXISTS ${CMAKE_CURRENT_LIST_DIR}/${name}/${name}.cpp) - target_sources(${libname} PRIVATE - ${name}/${name}.cpp - ) - else() - set(SKIP_TEST On) - endif() +set_target_properties(compatibility.test PROPERTIES + ENABLE_EXPORTS ON +) - string(REPLACE "-" "_" KITNAME ${name}) +file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}" COMPAT_SOURCE_DIR_CMAKE) +file(TO_CMAKE_PATH "${CMAKE_BINARY_DIR}" COMPAT_BUILD_DIR_CMAKE) - target_compile_definitions(${libname} PRIVATE - KIT_NAME=${KITNAME} - KIT_STRING="${name}" - KIT_FOLDER="${CMAKE_CURRENT_SOURCE_DIR}/${name}" - KIT_NDJSON_FILE="${CMAKE_CURRENT_SOURCE_DIR}/${name}/${name}.ndjson" - $<$:SKIP_TEST> - ) -endfunction() +target_compile_definitions(compatibility.test PRIVATE + COMPAT_SOURCE_DIR="${COMPAT_SOURCE_DIR_CMAKE}" + COMPAT_BUILD_DIR="${COMPAT_BUILD_DIR_CMAKE}" + COMPAT_PLUGIN_DIR="$" +) -file(GLOB kits RELATIVE ${CMAKE_CURRENT_LIST_DIR} ${CMAKE_CURRENT_LIST_DIR}/*) -foreach(kit ${kits}) - if (IS_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}/${kit}) - add_compatibility_kit(${kit}) +if(CCR_BUILD_TESTS) + if(NOT CMAKE_CROSSCOMPILING AND NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + gtest_discover_tests(compatibility.test DISCOVERY_MODE PRE_TEST) + else() + add_test(NAME compatibility.test COMMAND compatibility.test) endif() -endforeach() +endif() diff --git a/compatibility/TestCompatibility.cpp b/compatibility/TestCompatibility.cpp index 02ddc3bb..620e5b4e 100644 --- a/compatibility/TestCompatibility.cpp +++ b/compatibility/TestCompatibility.cpp @@ -1,26 +1,88 @@ #include "BaseCompatibility.hpp" +#include "cucumber_cpp/library/plugin/DynamicLibrary.hpp" +#include "gtest/gtest.h" +#include +#include +#include #include +#include +#include -#ifndef KIT_FOLDER -#error KIT_FOLDER is not defined -#endif +namespace compatibility +{ + namespace + { + std::vector DiscoverKits() + { + std::vector kits; -#ifndef KIT_NDJSON_FILE -#error KIT_NDJSON_FILE is not defined -#endif + const std::filesystem::path sourceDir{ COMPAT_SOURCE_DIR }; + const std::filesystem::path buildDir{ COMPAT_BUILD_DIR }; + const std::filesystem::path pluginDir{ COMPAT_PLUGIN_DIR }; -TEST(CompatibilityTest, KIT_NAME) -{ -#ifdef SKIP_TEST - GTEST_SKIP(); -#endif - - compatibility::RunDevkit({ - .paths = { KIT_FOLDER }, - .tagExpression = "", - .retry = 0, - .ndjsonFile = KIT_NDJSON_FILE, - .folder = KIT_FOLDER, - .kitString = KIT_STRING, - }); + for (const auto& entry : std::filesystem::directory_iterator{ sourceDir }) + { + if (!entry.is_directory()) + continue; + + const auto name = entry.path().filename().string(); + const auto ndjsonFile = entry.path() / (name + ".ndjson"); + + if (!std::filesystem::exists(ndjsonFile)) + continue; + + const auto pluginName = "compatibility." + name + ".plugin" + std::string{ cucumber_cpp::library::plugin::DynamicLibrary::PlatformExtension() }; + const auto pluginPath = pluginDir / pluginName; + + std::vector extraArgs; + const auto argsFile = entry.path() / (name + ".arguments.txt"); + if (std::filesystem::exists(argsFile)) + { + std::ifstream ifs{ argsFile }; + std::string arg; + while (ifs >> arg) + extraArgs.push_back(arg); + } + + kits.push_back(KitInfo{ + .name = name, + .sourceDir = entry.path(), + .buildDir = buildDir / "compatibility" / name, + .ndjsonFile = ndjsonFile, + .pluginPath = pluginPath, + .extraArgs = std::move(extraArgs), + .hasPlugin = std::filesystem::exists(entry.path() / (name + ".cpp")), + }); + } + + std::ranges::sort(kits, {}, &KitInfo::name); + return kits; + } + + std::string KitInfoToName(const testing::TestParamInfo& info) + { + auto name = info.param.name; + std::ranges::replace(name, '-', '_'); + return name; + } + + class CompatibilityTest : public testing::TestWithParam + {}; + } + + TEST_P(CompatibilityTest, Kit) + { + const auto& kit = GetParam(); + + if (!kit.hasPlugin) + GTEST_SKIP() << "No plugin for kit: " << kit.name; + + RunDevkit(kit); + } + + INSTANTIATE_TEST_SUITE_P( + Compatibility, + CompatibilityTest, + testing::ValuesIn(DiscoverKits()), + KitInfoToName); } diff --git a/compatibility/attachments/attachments.cpp b/compatibility/attachments/attachments.cpp index eb23409d..6687ad40 100644 --- a/compatibility/attachments/attachments.cpp +++ b/compatibility/attachments/attachments.cpp @@ -10,7 +10,7 @@ namespace { - const std::filesystem::path compatibilityPath = std::filesystem::path{ KIT_FOLDER }; + const std::filesystem::path compatibilityPath = std::filesystem::path{ std::source_location::current().file_name() }.parent_path(); } WHEN(R"(the string {string} is attached as {string})", (const std::string& text, const std::string& mediaType)) diff --git a/compatibility/empty/empty.cpp b/compatibility/empty/empty.cpp index e69de29b..f7d1ed55 100644 --- a/compatibility/empty/empty.cpp +++ b/compatibility/empty/empty.cpp @@ -0,0 +1 @@ +// intentionally empty - no steps or hooks for this kit diff --git a/compatibility/examples-tables-attachment/examples-tables-attachment.cpp b/compatibility/examples-tables-attachment/examples-tables-attachment.cpp index 155f9301..2e165df5 100644 --- a/compatibility/examples-tables-attachment/examples-tables-attachment.cpp +++ b/compatibility/examples-tables-attachment/examples-tables-attachment.cpp @@ -8,7 +8,7 @@ namespace { - const std::filesystem::path compatibilityPath = std::filesystem::path{ KIT_FOLDER }; + const std::filesystem::path compatibilityPath = std::filesystem::path{ std::source_location::current().file_name() }.parent_path(); } WHEN(R"(a JPEG image is attached)") diff --git a/compatibility/hooks-attachment/hooks-attachment.cpp b/compatibility/hooks-attachment/hooks-attachment.cpp index 6731236a..9643ae39 100644 --- a/compatibility/hooks-attachment/hooks-attachment.cpp +++ b/compatibility/hooks-attachment/hooks-attachment.cpp @@ -7,7 +7,7 @@ namespace { - const std::filesystem::path compatibilityPath = std::filesystem::path{ KIT_FOLDER }; + const std::filesystem::path compatibilityPath = std::filesystem::path{ std::source_location::current().file_name() }.parent_path(); } HOOK_BEFORE_SCENARIO() diff --git a/compatibility/retry-undefined/retry-undefined.cpp b/compatibility/retry-undefined/retry-undefined.cpp index e69de29b..f7d1ed55 100644 --- a/compatibility/retry-undefined/retry-undefined.cpp +++ b/compatibility/retry-undefined/retry-undefined.cpp @@ -0,0 +1 @@ +// intentionally empty - no steps or hooks for this kit diff --git a/cucumber_cpp/acceptance_test/CMakeLists.txt b/cucumber_cpp/acceptance_test/CMakeLists.txt index b04cd4c8..c99a6fd8 100644 --- a/cucumber_cpp/acceptance_test/CMakeLists.txt +++ b/cucumber_cpp/acceptance_test/CMakeLists.txt @@ -31,3 +31,4 @@ target_link_libraries(cucumber_cpp.acceptance_test.unused PRIVATE add_subdirectory(hooks) add_subdirectory(steps) +add_subdirectory(plugin_test) diff --git a/cucumber_cpp/acceptance_test/plugin_test/CMakeLists.txt b/cucumber_cpp/acceptance_test/plugin_test/CMakeLists.txt new file mode 100644 index 00000000..5d9885e4 --- /dev/null +++ b/cucumber_cpp/acceptance_test/plugin_test/CMakeLists.txt @@ -0,0 +1,36 @@ +include(${PROJECT_SOURCE_DIR}/cmake/ccr_add_plugin.cmake) + +# Test executable with statically linked step that loads plugins +add_executable(cucumber_cpp.acceptance_test.plugin ${CCR_EXCLUDE_FROM_ALL}) + +target_sources(cucumber_cpp.acceptance_test.plugin PRIVATE + StaticSteps.cpp + MainPlugin.cpp +) + +target_link_libraries(cucumber_cpp.acceptance_test.plugin PRIVATE + cucumber_cpp +) + +set_target_properties(cucumber_cpp.acceptance_test.plugin PROPERTIES + ENABLE_EXPORTS ON +) + +file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/features" PLUGIN_TEST_FEATURES_DIR_CMAKE) + +target_compile_definitions(cucumber_cpp.acceptance_test.plugin PRIVATE + PLUGIN_TEST_FEATURES_DIR="${PLUGIN_TEST_FEATURES_DIR_CMAKE}" + PLUGIN_TEST_PLUGIN_DIR="$" +) + +if (NOT WIN32) + target_link_libraries(cucumber_cpp.acceptance_test.plugin PRIVATE + $ + ) +endif() + +# Plugin A: steps + custom parameter type +ccr_add_plugin(cucumber_cpp.acceptance_test.plugin_a PluginA.cpp) + +# Plugin B: hooks + steps +ccr_add_plugin(cucumber_cpp.acceptance_test.plugin_b PluginB.cpp) diff --git a/cucumber_cpp/acceptance_test/plugin_test/MainPlugin.cpp b/cucumber_cpp/acceptance_test/plugin_test/MainPlugin.cpp new file mode 100644 index 00000000..acd2acff --- /dev/null +++ b/cucumber_cpp/acceptance_test/plugin_test/MainPlugin.cpp @@ -0,0 +1,63 @@ +#include "cucumber_cpp/CucumberCpp.hpp" +#include "cucumber_cpp/library/plugin/DynamicLibrary.hpp" +#include +#include +#include +#include + +namespace +{ + const std::string featuresDir{ PLUGIN_TEST_FEATURES_DIR }; + const std::string pluginDir{ PLUGIN_TEST_PLUGIN_DIR }; + + std::string PluginPath(const std::string& name) + { + return pluginDir + "/" + name + std::string{ cucumber_cpp::library::plugin::DynamicLibrary::PlatformExtension() }; + } + + int RunApplication(const std::vector& extraArgs) + { + std::vector argStrings; + argStrings.emplace_back("plugin_test"); + argStrings.emplace_back("--format"); + argStrings.emplace_back("summary"); + + for (const auto& arg : extraArgs) + argStrings.emplace_back(arg); + + argStrings.emplace_back("--no-recursive"); + argStrings.emplace_back(featuresDir); + + std::vector argv; + argv.reserve(argStrings.size()); + for (const auto& s : argStrings) + argv.push_back(s.c_str()); + + cucumber_cpp::Application app{}; + return app.Run(static_cast(argv.size()), argv.data()); + } +} + +int main() +{ + const auto pluginA = PluginPath("cucumber_cpp.acceptance_test.plugin_a"); + const auto pluginB = PluginPath("cucumber_cpp.acceptance_test.plugin_b"); + + // First run: plugin A (steps + custom parameter) with statically linked step + auto result = RunApplication({ "--load", pluginA, "--tags", "@plugin_a and not @plugin_b_hook" }); + if (result != 0) + { + std::cerr << "FAILED: First run with plugin A\n"; + return EXIT_FAILURE; + } + + // Second run: plugin B (hooks + steps) with statically linked step + result = RunApplication({ "--load", pluginB, "--tags", "@plugin_b_hook and not @plugin_a" }); + if (result != 0) + { + std::cerr << "FAILED: Second run with plugin B\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/cucumber_cpp/acceptance_test/plugin_test/PluginA.cpp b/cucumber_cpp/acceptance_test/plugin_test/PluginA.cpp new file mode 100644 index 00000000..22beb024 --- /dev/null +++ b/cucumber_cpp/acceptance_test/plugin_test/PluginA.cpp @@ -0,0 +1,39 @@ +#include "cucumber_cpp/Steps.hpp" +#include "gtest/gtest.h" +#include + +namespace +{ + struct Color + { + std::string name; + int r; + int g; + int b; + }; +} + +PARAMETER(Color, ("color", "(red|green|blue)", false), (const std::string& name)) +{ + if (name == "red") + return Color{ .name = "red", .r = 255, .g = 0, .b = 0 }; + if (name == "green") + return Color{ .name = "green", .r = 0, .g = 255, .b = 0 }; + return Color{ .name = "blue", .r = 0, .g = 0, .b = 255 }; +} + +THEN("plugin A can read the static context") +{ + ASSERT_TRUE(context.Get("static_step_ran")); + context.InsertAt("plugin_a_wrote", true); +} + +WHEN("a {color} is selected", (const Color& color)) +{ + context.InsertAt("selected_color", color.name); +} + +THEN("the color name is {string}", (const std::string& expected)) +{ + ASSERT_EQ(context.Get("selected_color"), expected); +} diff --git a/cucumber_cpp/acceptance_test/plugin_test/PluginB.cpp b/cucumber_cpp/acceptance_test/plugin_test/PluginB.cpp new file mode 100644 index 00000000..26668d22 --- /dev/null +++ b/cucumber_cpp/acceptance_test/plugin_test/PluginB.cpp @@ -0,0 +1,25 @@ +#include "cucumber_cpp/Steps.hpp" +#include "gtest/gtest.h" +#include + +HOOK_BEFORE_SCENARIO("@plugin_b_hook") +{ + std::cout << "PLUGIN_B_BEFORE_SCENARIO\n"; + context.InsertAt("plugin_b_hook_ran", true); +} + +HOOK_AFTER_SCENARIO("@plugin_b_hook") +{ + std::cout << "PLUGIN_B_AFTER_SCENARIO\n"; +} + +THEN("plugin B can read the static context") +{ + ASSERT_TRUE(context.Get("static_step_ran")); + context.InsertAt("plugin_b_wrote", true); +} + +THEN("plugin B hook was executed") +{ + ASSERT_TRUE(context.Get("plugin_b_hook_ran")); +} diff --git a/cucumber_cpp/acceptance_test/plugin_test/StaticSteps.cpp b/cucumber_cpp/acceptance_test/plugin_test/StaticSteps.cpp new file mode 100644 index 00000000..07f1c0ba --- /dev/null +++ b/cucumber_cpp/acceptance_test/plugin_test/StaticSteps.cpp @@ -0,0 +1,18 @@ +#include "cucumber_cpp/Steps.hpp" +#include "gtest/gtest.h" +#include + +GIVEN("a statically linked step") +{ + context.InsertAt("static_step_ran", true); +} + +THEN("the static step can read plugin A context") +{ + ASSERT_TRUE(context.Get("plugin_a_wrote")); +} + +THEN("the static step can read plugin B context") +{ + ASSERT_TRUE(context.Get("plugin_b_wrote")); +} diff --git a/cucumber_cpp/acceptance_test/plugin_test/features/test_plugins.feature b/cucumber_cpp/acceptance_test/plugin_test/features/test_plugins.feature new file mode 100644 index 00000000..f356f06c --- /dev/null +++ b/cucumber_cpp/acceptance_test/plugin_test/features/test_plugins.feature @@ -0,0 +1,35 @@ +@plugin +Feature: Plugin loading with static steps + + @plugin_a + Scenario: Plugin A provides custom parameter type + Given a statically linked step + Then plugin A can read the static context + When a red is selected + Then the color name is "red" + And the static step can read plugin A context + + @plugin_a + Scenario: Plugin A with different color + Given a statically linked step + Then plugin A can read the static context + When a blue is selected + Then the color name is "blue" + + @plugin_b_hook + Scenario: Plugin B provides hooks and steps + Given a statically linked step + Then plugin B can read the static context + And the static step can read plugin B context + And plugin B hook was executed + + @plugin_a @plugin_b_hook + Scenario: Both plugins loaded together + Given a statically linked step + Then plugin A can read the static context + And plugin B can read the static context + When a green is selected + Then the color name is "green" + And the static step can read plugin A context + And the static step can read plugin B context + And plugin B hook was executed diff --git a/cucumber_cpp/acceptance_test/test.bats b/cucumber_cpp/acceptance_test/test.bats index fa3a1a9c..40fbe2e7 100644 --- a/cucumber_cpp/acceptance_test/test.bats +++ b/cucumber_cpp/acceptance_test/test.bats @@ -1,8 +1,9 @@ #!/usr/bin/env bats setup_file() { - acceptance_test=$(find . -name "cucumber_cpp.acceptance_test" -print -quit) - export acceptance_test + acceptance_test=$(find . -name "cucumber_cpp.acceptance_test" -not -name "*.plugin*" -print -quit) + plugin_test=$(find . -name "cucumber_cpp.acceptance_test.plugin" -not -name "*.plugin_*" -print -quit) + export acceptance_test plugin_test } setup() { @@ -252,3 +253,8 @@ teardown() { assert_output --partial "Parse error in: \"cucumber_cpp/acceptance_test/features_with_parse_error/test_parse_error.feature:4:9\"" assert_output --partial "got 'when this line is a parse error (when should be When)'" } + +@test "Plugin test: load two plugins sequentially with static step" { + run $plugin_test + assert_success +} diff --git a/cucumber_cpp/example/CMakeLists.txt b/cucumber_cpp/example/CMakeLists.txt index f8d2ef00..8c72db82 100644 --- a/cucumber_cpp/example/CMakeLists.txt +++ b/cucumber_cpp/example/CMakeLists.txt @@ -13,5 +13,10 @@ target_link_libraries(cucumber_cpp.example PRIVATE cucumber_cpp.example.steps ) +set_target_properties(cucumber_cpp.example PROPERTIES + ENABLE_EXPORTS ON +) + add_subdirectory(hooks) +add_subdirectory(plugin) add_subdirectory(steps) diff --git a/cucumber_cpp/example/plugin/CMakeLists.txt b/cucumber_cpp/example/plugin/CMakeLists.txt new file mode 100644 index 00000000..177e339b --- /dev/null +++ b/cucumber_cpp/example/plugin/CMakeLists.txt @@ -0,0 +1,8 @@ +include(${PROJECT_SOURCE_DIR}/cmake/ccr_add_plugin.cmake) + +ccr_add_plugin(cucumber_cpp.example.plugin) + +target_sources(cucumber_cpp.example.plugin PRIVATE + PluginSteps.cpp + PluginHooks.cpp +) diff --git a/cucumber_cpp/example/plugin/PluginHooks.cpp b/cucumber_cpp/example/plugin/PluginHooks.cpp new file mode 100644 index 00000000..263a879f --- /dev/null +++ b/cucumber_cpp/example/plugin/PluginHooks.cpp @@ -0,0 +1,12 @@ +#include "cucumber_cpp/library/Hooks.hpp" +#include + +HOOK_BEFORE_SCENARIO() +{ + std::cout << "PLUGIN_HOOK_BEFORE_SCENARIO\n"; +} + +HOOK_AFTER_SCENARIO() +{ + std::cout << "PLUGIN_HOOK_AFTER_SCENARIO\n"; +} diff --git a/cucumber_cpp/example/plugin/PluginSteps.cpp b/cucumber_cpp/example/plugin/PluginSteps.cpp new file mode 100644 index 00000000..b335912a --- /dev/null +++ b/cucumber_cpp/example/plugin/PluginSteps.cpp @@ -0,0 +1,19 @@ +#include "cucumber_cpp/library/Context.hpp" +#include "cucumber_cpp/library/Steps.hpp" +#include + +GIVEN(R"(the greeter says {string})", (std::string greeting)) +{ + context.InsertAt("greeting", greeting); +} + +WHEN(R"(the user responds)") +{ + const auto& greeting = context.Get("greeting"); + context.InsertAt("response", std::string{ "You said: " + greeting }); +} + +THEN(R"(the conversation is complete)") +{ + [[maybe_unused]] const auto& response = context.Get("response"); +} diff --git a/cucumber_cpp/example/plugin/features/plugin.feature b/cucumber_cpp/example/plugin/features/plugin.feature new file mode 100644 index 00000000..468d2b18 --- /dev/null +++ b/cucumber_cpp/example/plugin/features/plugin.feature @@ -0,0 +1,7 @@ +Feature: Plugin example + Demonstrates steps loaded from a dynamic library plugin. + + Scenario: Greeting + Given the greeter says "Hello" + When the user responds + Then the conversation is complete diff --git a/cucumber_cpp/library/Application.cpp b/cucumber_cpp/library/Application.cpp index 51516fcc..f485707a 100644 --- a/cucumber_cpp/library/Application.cpp +++ b/cucumber_cpp/library/Application.cpp @@ -1,4 +1,10 @@ #include "cucumber_cpp/library/Application.hpp" +#include "CLI/App.hpp" +#include "CLI/CLI.hpp" +#include "CLI/Error.hpp" +#include "CLI/Option.hpp" +#include "CLI/Validators.hpp" +#include "CLI/impl/App_inl.hpp" #include "cucumber/gherkin/demangle.hpp" #include "cucumber_cpp/library/Context.hpp" #include "cucumber_cpp/library/Errors.hpp" @@ -6,24 +12,19 @@ #include "cucumber_cpp/library/api/RunCucumber.hpp" #include "cucumber_cpp/library/cucumber_expression/Errors.hpp" #include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/plugin/DynamicLibraryManager.hpp" +#include "cucumber_cpp/library/support/DefinitionRegistration.hpp" #include "cucumber_cpp/library/support/Types.hpp" #include "cucumber_cpp/library/tag_expression/Parser.hpp" #include "fmt/base.h" #include "fmt/format.h" #include "fmt/ranges.h" -#include -#include -#include -#include -#include -#include #include #include #include #include #include #include -#include #include #include #include @@ -34,6 +35,30 @@ namespace cucumber_cpp::library { + struct PluginSession + { + explicit PluginSession(plugin::DynamicLibraryManager& manager, const std::vector& paths) + : manager{ manager } + { + support::DefinitionRegistration::Instance().TakeSnapshot(); + cucumber_expression::ConverterRegistry::TakeSnapshot(); + manager.Load(paths); + } + + ~PluginSession() + { + support::DefinitionRegistration::Instance().UnregisterPlugins(); + cucumber_expression::ConverterRegistry::RestoreSnapshot(); + manager.UnloadAll(); + } + + PluginSession(const PluginSession&) = delete; + PluginSession& operator=(const PluginSession&) = delete; + + private: + plugin::DynamicLibraryManager& manager; + }; + namespace { bool IsFeatureFile(const std::filesystem::directory_entry& entry) @@ -51,7 +76,7 @@ namespace cucumber_cpp::library foundFiles.emplace(entry.path()); } - std::set> GetFeatureFiles(Application::Options& options) + std::set> GetFeatureFiles(const Application::Options& options) { std::set> foundFiles; @@ -65,13 +90,19 @@ namespace cucumber_cpp::library } } - Application::Application(std::shared_ptr contextStorageFactory, bool removeDefaultGoogleTestListener) - : contextStorageFactory{ contextStorageFactory } + Application::Application(std::shared_ptr contextStorageFactory, bool removeDefaultGoogleTestListener, + std::unique_ptr stopwatch, + std::unique_ptr timestampGenerator) + : contextStorageFactory{ std::move(contextStorageFactory) } , removeDefaultGoogleTestListener{ removeDefaultGoogleTestListener } + , stopwatch{ std::move(stopwatch) } + , timestampGenerator{ std::move(timestampGenerator) } { cli.set_config("--config", "cucumber.toml"); } + Application::~Application() = default; + int Application::Run(int argc, const char* const* argv) { const auto formattersSet = formatters.GetAvailableFormatterNames(); @@ -125,6 +156,8 @@ namespace cucumber_cpp::library CLI::deprecate_option(cli.add_option("-f,--feature", options.paths, "Paths to where your feature files are"), "paths"); cli.add_option("paths", options.paths, "Paths to where your feature files are, defaults to \"./features\"")->default_val(options.paths); + cli.add_option("--load", options.loadPaths, "Load cucumber step/hook/parameter dynamic libraries from files or directories"); + ProgramContext().InsertRef(options); cli.parse(argc, argv); @@ -132,6 +165,9 @@ namespace cucumber_cpp::library if (options.dumpConfig) std::ofstream{ "cucumber.toml" } << cli.config_to_str(true, true); + if (!options.loadPaths.empty()) + pluginSession = std::make_unique(dynamicLibraryManager, options.loadPaths); + return RunFeatures(); } catch (const CLI::ParseError& e) diff --git a/cucumber_cpp/library/Application.hpp b/cucumber_cpp/library/Application.hpp index 94cabed4..460f01aa 100644 --- a/cucumber_cpp/library/Application.hpp +++ b/cucumber_cpp/library/Application.hpp @@ -7,6 +7,7 @@ #include "cucumber_cpp/library/Context.hpp" #include "cucumber_cpp/library/api/Formatters.hpp" #include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/plugin/DynamicLibraryManager.hpp" #include "cucumber_cpp/library/support/DefinitionRegistration.hpp" #include "cucumber_cpp/library/support/SupportCodeLibrary.hpp" #include "cucumber_cpp/library/support/Types.hpp" @@ -26,6 +27,8 @@ namespace cucumber_cpp::library { + struct PluginSession; + struct Application { struct Options @@ -55,9 +58,15 @@ namespace cucumber_cpp::library bool recursive{ true }; std::vector tags{}; + + std::vector loadPaths{}; }; - explicit Application(std::shared_ptr contextStorageFactory = std::make_shared(), bool removeDefaultGoogleTestListener = true); + explicit Application(std::shared_ptr contextStorageFactory = std::make_shared(), bool removeDefaultGoogleTestListener = true, + std::unique_ptr stopwatch = std::make_unique(), + std::unique_ptr timestampGenerator = std::make_unique()); + + ~Application(); [[nodiscard]] int Run(int argc, const char* const* argv); @@ -82,10 +91,14 @@ namespace cucumber_cpp::library util::Broadcaster broadcaster; + plugin::DynamicLibraryManager dynamicLibraryManager; + + std::unique_ptr pluginSession; + cucumber_expression::ParameterRegistry parameterRegistry{ cucumber_cpp::library::support::DefinitionRegistration::Instance().GetRegisteredParameters() }; bool removeDefaultGoogleTestListener; - util::StopWatchHighResolutionClock stopwatchHighResolutionClock; - util::TimestampGeneratorSystemClock timestampGeneratorSystemClock; + std::unique_ptr stopwatch; + std::unique_ptr timestampGenerator; bool runPassed{ false }; }; diff --git a/cucumber_cpp/library/BodyMacro.hpp b/cucumber_cpp/library/BodyMacro.hpp index 4790fa55..c345d433 100644 --- a/cucumber_cpp/library/BodyMacro.hpp +++ b/cucumber_cpp/library/BodyMacro.hpp @@ -5,8 +5,9 @@ #include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" #include "cucumber_cpp/library/support/Body.hpp" #include "cucumber_cpp/library/util/Body.hpp" +#include "cucumber_cpp/library/util/TestStepResult.hpp" +#include "gtest/gtest.h" #include -#include #include #include #include @@ -23,7 +24,11 @@ namespace cucumber_cpp::library::detail struct BodyCrtp : util::Body { private: - BodyCrtp() = default; + explicit BodyCrtp(util::TestStepResult& testStepResult) + : util::Body{ testStepResult } + { + } + friend Base; public: @@ -57,24 +62,29 @@ namespace cucumber_cpp::library::detail #define BODY_STRUCT CONCAT(BodyImpl, __LINE__) -#define BODY(matcher, type, targs, registration, base) \ - namespace \ - { \ - struct BODY_STRUCT : cucumber_cpp::library::detail::BodyCrtp \ - , base \ - { \ - /* Workaround namespaces in `base`. For example `base` = Foo::Bar. */ \ - /* Then the result would be Foo::Bar::Foo::Bar which is invalid */ \ - using myBase = base; \ - using myBase::myBase; \ - \ - private: \ - friend BodyCrtp; \ - void ExecuteImpl targs; \ - static const std::size_t ID; \ - }; \ - } \ - const std::size_t BODY_STRUCT::ID = registration(matcher, type); \ +#define BODY(matcher, type, targs, registration, base) \ + namespace \ + { \ + struct BODY_STRUCT : cucumber_cpp::library::detail::BodyCrtp \ + , base \ + { \ + /* Workaround namespaces in `base`. For example `base` = Foo::Bar. */ \ + /* Then the result would be Foo::Bar::Foo::Bar which is invalid */ \ + using myBase = base; \ + using myBase::myBase; \ + template \ + BODY_STRUCT(cucumber_cpp::library::util::TestStepResult& testStepResult, CTArgs&&... args) \ + : BodyCrtp{ testStepResult } \ + , myBase{ std::forward(args)... } \ + {} \ + \ + private: \ + friend BodyCrtp; \ + void ExecuteImpl targs; \ + static const std::size_t ID; \ + }; \ + } \ + const std::size_t BODY_STRUCT::ID = registration(matcher, type); \ void BODY_STRUCT::ExecuteImpl targs #endif diff --git a/cucumber_cpp/library/CMakeLists.txt b/cucumber_cpp/library/CMakeLists.txt index 7b81f688..3cab5c8b 100644 --- a/cucumber_cpp/library/CMakeLists.txt +++ b/cucumber_cpp/library/CMakeLists.txt @@ -21,6 +21,7 @@ target_link_libraries(cucumber_cpp.library PUBLIC cucumber_gherkin_lib cucumber_cpp.library.api cucumber_cpp.library.engine + cucumber_cpp.library.plugin cucumber_cpp.library.tag_expression cucumber_cpp.library.util cucumber_cpp.library.formatter @@ -41,6 +42,7 @@ add_subdirectory(assemble) add_subdirectory(cucumber_expression) add_subdirectory(engine) add_subdirectory(formatter) +add_subdirectory(plugin) add_subdirectory(query) add_subdirectory(runtime) add_subdirectory(support) diff --git a/cucumber_cpp/library/api/RunCucumber.cpp b/cucumber_cpp/library/api/RunCucumber.cpp index 4bb82dc7..8f2864e2 100644 --- a/cucumber_cpp/library/api/RunCucumber.cpp +++ b/cucumber_cpp/library/api/RunCucumber.cpp @@ -13,6 +13,9 @@ #include "cucumber_cpp/library/api/Formatters.hpp" #include "cucumber_cpp/library/api/Gherkin.hpp" #include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/plugin/HookLoader.hpp" +#include "cucumber_cpp/library/plugin/ParameterLoader.hpp" +#include "cucumber_cpp/library/plugin/StepLoader.hpp" #include "cucumber_cpp/library/query/Query.hpp" #include "cucumber_cpp/library/runtime/MakeRuntime.hpp" #include "cucumber_cpp/library/support/DefinitionRegistration.hpp" @@ -121,15 +124,20 @@ namespace cucumber_cpp::library::api void EmitSupportCodeMessages(const support::SupportCodeLibrary& supportCodeLibrary, util::Broadcaster& broadcaster, const cucumber::gherkin::id_generator_ptr& idGenerator) { + // Phase 1: Load parameters (must be first, steps reference parameter types) + plugin::ParameterLoader::Load(support::DefinitionRegistration::Instance(), supportCodeLibrary.parameterRegistry); EmitParameters(supportCodeLibrary, broadcaster, idGenerator); support::DefinitionRegistration::Instance().LoadIds(idGenerator); - supportCodeLibrary.stepRegistry.LoadSteps(); + + // Phase 2: Load steps (can now resolve parameter type expressions) + plugin::StepLoader::Load(supportCodeLibrary.stepRegistry); EmitUndefinedParameters(supportCodeLibrary, broadcaster); EmitStepDefinitions(supportCodeLibrary, broadcaster); - supportCodeLibrary.hookRegistry.LoadHooks(); + // Phase 3: Load hooks (last, no ordering dependency) + plugin::HookLoader::Load(supportCodeLibrary.hookRegistry); EmitTestCaseHooks(supportCodeLibrary, broadcaster); EmitTestRunHooks(supportCodeLibrary, broadcaster); } diff --git a/cucumber_cpp/library/cucumber_expression/Argument.hpp b/cucumber_cpp/library/cucumber_expression/Argument.hpp index 7e146337..1b8ef2c2 100644 --- a/cucumber_cpp/library/cucumber_expression/Argument.hpp +++ b/cucumber_cpp/library/cucumber_expression/Argument.hpp @@ -13,13 +13,13 @@ namespace cucumber_cpp::library::cucumber_expression template T TransformArg([[maybe_unused]] const T& _, const std::string& name, const cucumber_expression::ConvertFunctionArg& match) { - return ConverterTypeMap>::Instance().at(name)(match).value(); + return ConverterTypeMap>::Instance().At(name)(match).value(); } template std::optional TransformArg([[maybe_unused]] const std::optional& _, const std::string& name, const cucumber_expression::ConvertFunctionArg& match) { - return ConverterTypeMap>::Instance().at(name)(match); + return ConverterTypeMap>::Instance().At(name)(match); } struct Argument diff --git a/cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp b/cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp index 9843f833..b225dcae 100644 --- a/cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp +++ b/cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp @@ -3,6 +3,7 @@ #include "fmt/format.h" #include +#include #include #include #include @@ -129,22 +130,129 @@ namespace cucumber_cpp::library::cucumber_expression template using ConverterFunction = std::function; - template - using TypeMap = std::map>; + // Type-erased converter used at the host/plugin ABI boundary. The stored + // std::function returns std::any so a single non-templated map can hold + // converters for all return types. std::any_cast is performed at the call + // site (host or plugin), while the underlying storage can be shared. + using AnyConverterFunction = std::function; - template - struct ConverterTypeMap + using ConverterMap = std::map>; + + // Central converter registry. Each DLL has its own default map, but the + // active pointer can be redirected to a shared (host-owned) map so + // registrations and lookups cross DLL boundaries safely. + struct ConverterRegistry { - static TypeMap& Instance(); + static ConverterMap& Instance() + { + return *ActivePtr(); + } + + static ConverterMap& LocalInstance() + { + static ConverterMap map; + return map; + } + + // Redirect this DLL's active converter map to an externally owned map + // (typically the host's). Passing nullptr restores the local map. + static void SetInstance(ConverterMap* external) + { + ActivePtr() = external != nullptr ? external : &LocalInstance(); + } + + static void TakeSnapshot() + { + Snapshot() = Instance(); + } + + static void RestoreSnapshot() + { + Instance() = Snapshot(); + } + + private: + static ConverterMap*& ActivePtr() + { + static ConverterMap* ptr = &LocalInstance(); + return ptr; + } + + static ConverterMap& Snapshot() + { + static ConverterMap snapshot; + return snapshot; + } }; + // Backwards-compatible typed accessor. Wraps/unwraps std::any so existing + // typed callers keep working, but storage is unified in ConverterRegistry. template - TypeMap& ConverterTypeMap::Instance() + struct ConverterTypeMap { - static TypeMap typeMap; - return typeMap; - } + // Proxy allowing typed insertion/lookup against the underlying any map. + struct Proxy + { + ConverterMap& map; + + void Emplace(const std::string& name, ConverterFunction fn) + { + map[name] = [fn = std::move(fn)](const ConvertFunctionArg& args) + { + return std::any{ fn(args) }; + }; + } + + struct TypedAccessor + { + AnyConverterFunction& fn; + + T operator()(const ConvertFunctionArg& args) const + { + return std::any_cast(fn(args)); + } + }; + + TypedAccessor At(const std::string& name) + { + return TypedAccessor{ map.at(name) }; + } + + struct Assigner + { + ConverterMap& map; + std::string name; + + Assigner& operator=(ConverterFunction fn) + { + map[name] = [fn = std::move(fn)](const ConvertFunctionArg& args) + { + return std::any{ fn(args) }; + }; + return *this; + } + }; + + Assigner operator[](const std::string& name) + { + return Assigner{ map, name }; + } + }; + + static Proxy Instance() + { + return Proxy{ ConverterRegistry::Instance() }; + } + }; +} + +namespace cucumber_cpp::library::plugin +{ + struct ParameterLoader; +} +namespace cucumber_cpp::library::cucumber_expression +{ struct ParameterRegistry { explicit ParameterRegistry(const std::set>& customParameters); @@ -159,9 +267,11 @@ namespace cucumber_cpp::library::cucumber_expression template void AddParameter(std::string name, std::vector regex, ConverterFunction converter, std::source_location location = std::source_location::current()); + private: + friend struct plugin::ParameterLoader; + void AssertParameterIsUnique(const std::string& name) const; - private: void AddParameter(ParameterType parameter); template @@ -193,7 +303,7 @@ namespace cucumber_cpp::library::cucumber_expression AddParameter(parameter); - ConverterTypeMap::Instance().emplace(parameter.name, converter); + ConverterTypeMap::Instance().Emplace(parameter.name, converter); } } diff --git a/cucumber_cpp/library/plugin/CMakeLists.txt b/cucumber_cpp/library/plugin/CMakeLists.txt new file mode 100644 index 00000000..5d5b6e59 --- /dev/null +++ b/cucumber_cpp/library/plugin/CMakeLists.txt @@ -0,0 +1,25 @@ +add_library(cucumber_cpp.library.plugin ${CCR_EXCLUDE_FROM_ALL}) + +target_sources(cucumber_cpp.library.plugin PRIVATE + DynamicLibrary.cpp + DynamicLibrary.hpp + DynamicLibraryManager.cpp + DynamicLibraryManager.hpp + HookLoader.cpp + HookLoader.hpp + ParameterLoader.cpp + ParameterLoader.hpp + PluginExport.hpp + StepLoader.cpp + StepLoader.hpp +) + +target_include_directories(cucumber_cpp.library.plugin PUBLIC + ../../.. +) + +target_link_libraries(cucumber_cpp.library.plugin PUBLIC + cucumber_cpp.library.support + cucumber_cpp.library.cucumber_expression + ${CMAKE_DL_LIBS} +) diff --git a/cucumber_cpp/library/plugin/DynamicLibrary.cpp b/cucumber_cpp/library/plugin/DynamicLibrary.cpp new file mode 100644 index 00000000..eb214d26 --- /dev/null +++ b/cucumber_cpp/library/plugin/DynamicLibrary.cpp @@ -0,0 +1,132 @@ +#include "cucumber_cpp/library/plugin/DynamicLibrary.hpp" +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#endif + +namespace cucumber_cpp::library::plugin +{ + namespace + { +#if defined(_WIN32) + std::string GetLastErrorMessage() + { + const DWORD error = GetLastError(); + + if (error == 0) + return "Unknown error"; + + LPSTR buffer = nullptr; + const auto size = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&buffer), 0, nullptr); // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) + + if (size == 0 || buffer == nullptr) + return "Unknown error (code " + std::to_string(error) + ")"; + + std::string message(buffer, size); + LocalFree(buffer); + return message; + } +#endif + } + + DynamicLibrary::DynamicLibrary(const std::filesystem::path& path) + : libraryPath{ path } + { +#if defined(_WIN32) + handle = LoadLibraryW(path.c_str()); + + if (handle == nullptr) + throw std::runtime_error("Failed to load library '" + path.string() + "': " + GetLastErrorMessage()); +#else + handle = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL); // NOLINT(hicpp-signed-bitwise) + + if (handle == nullptr) + throw std::runtime_error("Failed to load library '" + path.string() + "': " + dlerror()); +#endif + } + + DynamicLibrary::~DynamicLibrary() + { + if (handle != nullptr) + { +#if defined(_WIN32) + FreeLibrary(static_cast(handle)); +#else + dlclose(handle); +#endif + } + } + + DynamicLibrary::DynamicLibrary(DynamicLibrary&& other) noexcept + : libraryPath{ std::move(other.libraryPath) } + , handle{ std::exchange(other.handle, nullptr) } + {} + + DynamicLibrary& DynamicLibrary::operator=(DynamicLibrary&& other) noexcept + { + if (this != &other) + { + if (handle != nullptr) + { +#if defined(_WIN32) + FreeLibrary(static_cast(handle)); +#else + dlclose(handle); +#endif + } + + libraryPath = std::move(other.libraryPath); + handle = std::exchange(other.handle, nullptr); + } + + return *this; + } + + const std::filesystem::path& DynamicLibrary::Path() const + { + return libraryPath; + } + + std::string_view DynamicLibrary::PlatformExtension() + { +#if defined(_WIN32) + return ".dll"; +#elif defined(__APPLE__) + return ".dylib"; +#else + return ".so"; +#endif + } + + void* DynamicLibrary::GetSymbolAddress(std::string_view name) const + { + const std::string symbolName{ name }; + +#if defined(_WIN32) + void* symbol = reinterpret_cast(GetProcAddress(static_cast(handle), symbolName.c_str())); // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) + + if (symbol == nullptr) + throw std::runtime_error("Symbol '" + symbolName + "' not found in library '" + libraryPath.string() + "': " + GetLastErrorMessage()); +#else + dlerror(); // clear previous error + void* symbol = dlsym(handle, symbolName.c_str()); + const char* error = dlerror(); + + if (error != nullptr) + throw std::runtime_error("Symbol '" + symbolName + "' not found in library '" + libraryPath.string() + "': " + error); +#endif + + return symbol; + } +} diff --git a/cucumber_cpp/library/plugin/DynamicLibrary.hpp b/cucumber_cpp/library/plugin/DynamicLibrary.hpp new file mode 100644 index 00000000..faf4a58c --- /dev/null +++ b/cucumber_cpp/library/plugin/DynamicLibrary.hpp @@ -0,0 +1,47 @@ +#ifndef PLUGIN_DYNAMIC_LIBRARY_HPP +#define PLUGIN_DYNAMIC_LIBRARY_HPP + +#include +#include +#include + +namespace cucumber_cpp::library::plugin +{ + struct DynamicLibrary + { + explicit DynamicLibrary(const std::filesystem::path& path); + + ~DynamicLibrary(); + + DynamicLibrary(const DynamicLibrary&) = delete; + DynamicLibrary& operator=(const DynamicLibrary&) = delete; + + DynamicLibrary(DynamicLibrary&& other) noexcept; + DynamicLibrary& operator=(DynamicLibrary&& other) noexcept; + + template + FnPtr GetSymbol(std::string_view name) const; + + [[nodiscard]] const std::filesystem::path& Path() const; + + static std::string_view PlatformExtension(); + + private: + void* GetSymbolAddress(std::string_view name) const; + + std::filesystem::path libraryPath; + void* handle{ nullptr }; + }; + + ////////////////////////// + // implementation // + ////////////////////////// + + template + FnPtr DynamicLibrary::GetSymbol(std::string_view name) const + { + return reinterpret_cast(GetSymbolAddress(name)); // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) + } +} + +#endif diff --git a/cucumber_cpp/library/plugin/DynamicLibraryManager.cpp b/cucumber_cpp/library/plugin/DynamicLibraryManager.cpp new file mode 100644 index 00000000..ba20c545 --- /dev/null +++ b/cucumber_cpp/library/plugin/DynamicLibraryManager.cpp @@ -0,0 +1,80 @@ +#include "cucumber_cpp/library/plugin/DynamicLibraryManager.hpp" +#include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/plugin/DynamicLibrary.hpp" +#include "cucumber_cpp/library/plugin/PluginExport.hpp" +#include "cucumber_cpp/library/support/DefinitionRegistration.hpp" +#include "cucumber_cpp/library/util/Duration.hpp" +#include "cucumber_cpp/library/util/Timestamp.hpp" +#include +#include +#include +#include +#include + +namespace cucumber_cpp::library::plugin +{ + void DynamicLibraryManager::Load(const std::vector& paths) + { + for (const auto& path : paths) + { + const std::filesystem::path fsPath{ path }; + + if (std::filesystem::is_directory(fsPath)) + LoadDirectory(fsPath); + else if (std::filesystem::is_regular_file(fsPath)) + LoadFile(fsPath); + else + throw std::runtime_error("Path '" + path + "' is not a file or directory"); + } + } + + void DynamicLibraryManager::UnloadAll() + { + libraries.clear(); + } + + std::vector DynamicLibraryManager::GetLoadedLibraries() const + { + std::vector paths; + paths.reserve(libraries.size()); + + for (const auto& lib : libraries) + paths.push_back(lib.Path()); + + return paths; + } + + void DynamicLibraryManager::LoadFile(const std::filesystem::path& path) + { + const auto& lib = libraries.emplace_back(path); + + auto registerFn = lib.GetSymbol("ccr_register"); + + PluginHostContext context{ + .abiVersion = pluginAbiVersion, + .structSize = sizeof(PluginHostContext), + .registration = &support::DefinitionRegistration::Instance(), + .stopwatch = &util::Stopwatch::Instance(), + .timestampGenerator = &util::TimestampGenerator::Instance(), + .converterMap = &cucumber_expression::ConverterRegistry::Instance(), + }; + + registerFn(&context); + } + + void DynamicLibraryManager::LoadDirectory(const std::filesystem::path& directory) + { + const auto extension = DynamicLibrary::PlatformExtension(); + + std::vector sortedPaths; + + for (const auto& entry : std::filesystem::directory_iterator{ directory }) + if (std::filesystem::is_regular_file(entry) && entry.path().extension() == extension) + sortedPaths.push_back(entry.path()); + + std::ranges::sort(sortedPaths); + + for (const auto& path : sortedPaths) + LoadFile(path); + } +} diff --git a/cucumber_cpp/library/plugin/DynamicLibraryManager.hpp b/cucumber_cpp/library/plugin/DynamicLibraryManager.hpp new file mode 100644 index 00000000..335b6892 --- /dev/null +++ b/cucumber_cpp/library/plugin/DynamicLibraryManager.hpp @@ -0,0 +1,26 @@ +#ifndef PLUGIN_DYNAMIC_LIBRARY_MANAGER_HPP +#define PLUGIN_DYNAMIC_LIBRARY_MANAGER_HPP + +#include "cucumber_cpp/library/plugin/DynamicLibrary.hpp" +#include +#include +#include + +namespace cucumber_cpp::library::plugin +{ + struct DynamicLibraryManager + { + void Load(const std::vector& paths); + void UnloadAll(); + + [[nodiscard]] std::vector GetLoadedLibraries() const; + + private: + void LoadFile(const std::filesystem::path& path); + void LoadDirectory(const std::filesystem::path& directory); + + std::vector libraries; + }; +} + +#endif diff --git a/cucumber_cpp/library/plugin/HookLoader.cpp b/cucumber_cpp/library/plugin/HookLoader.cpp new file mode 100644 index 00000000..463dd9a1 --- /dev/null +++ b/cucumber_cpp/library/plugin/HookLoader.cpp @@ -0,0 +1,10 @@ +#include "cucumber_cpp/library/plugin/HookLoader.hpp" +#include "cucumber_cpp/library/support/HookRegistry.hpp" + +namespace cucumber_cpp::library::plugin +{ + void HookLoader::Load(support::HookRegistry& hookRegistry) + { + hookRegistry.LoadHooks(); + } +} diff --git a/cucumber_cpp/library/plugin/HookLoader.hpp b/cucumber_cpp/library/plugin/HookLoader.hpp new file mode 100644 index 00000000..f012ddd9 --- /dev/null +++ b/cucumber_cpp/library/plugin/HookLoader.hpp @@ -0,0 +1,14 @@ +#ifndef PLUGIN_HOOK_LOADER_HPP +#define PLUGIN_HOOK_LOADER_HPP + +#include "cucumber_cpp/library/support/HookRegistry.hpp" + +namespace cucumber_cpp::library::plugin +{ + struct HookLoader + { + static void Load(support::HookRegistry& hookRegistry); + }; +} + +#endif diff --git a/cucumber_cpp/library/plugin/PLUGIN_ARCHITECTURE.md b/cucumber_cpp/library/plugin/PLUGIN_ARCHITECTURE.md new file mode 100644 index 00000000..43030421 --- /dev/null +++ b/cucumber_cpp/library/plugin/PLUGIN_ARCHITECTURE.md @@ -0,0 +1,422 @@ +# Plugin Architecture + +This document describes the dynamic plugin loading system for cucumber-cpp-runner. +Plugins are shared libraries (`.so` / `.dylib` / `.dll`) that register steps, hooks, +and parameter types at runtime via the same macros used in statically-linked code. + +--- + +## Overview + +The plugin system enables loading cucumber step definitions, hooks, and custom +parameter types from shared libraries at runtime via the `--load` CLI option. +This allows test code to be compiled independently of the main runner executable +and loaded on demand. + +Key design principles: + +- **Transparent authoring** — plugin source files use the same `STEP`, `HOOK_*`, + and `PARAMETER` macros as statically-linked code. +- **Registrations stay in-place** — each plugin keeps its own + `DefinitionRegistration` instance; the host holds references and aggregates + lookups across all plugins. +- **Cross-platform** — works on Linux (`.so`), macOS (`.dylib`), and Windows (`.dll`). +- **Validation** — each plugin must export a `ccr_register` symbol (provided + automatically by `ccr_add_plugin`) as an entry-point validation gate. + +--- + +## Object Diagram + +```mermaid +classDiagram + class Application { + -Options options + -CLI::App cli + -Broadcaster broadcaster + -DynamicLibraryManager dynamicLibraryManager + -ParameterRegistry parameterRegistry + -Formatters formatters + +Run(argc, argv) int + +~Application() + } + + class DynamicLibraryManager { + -vector~DynamicLibrary~ libraries + +Load(paths) + +UnloadAll() + +GetLoadedLibraries() vector~path~ + -LoadFile(path) + -LoadDirectory(path) + } + + class DynamicLibrary { + -void* handle + -path libraryPath + +DynamicLibrary(path) + +~DynamicLibrary() + +GetSymbol~FnPtr~(name) FnPtr + +PlatformExtension()$ string_view + } + + class DefinitionRegistration { + -map registry + -set customParameters + -vector~DefinitionRegistration*~ plugins + +Instance()$ DefinitionRegistration& + +Register~Body~() size_t + +RegisterPlugin(plugin) + +UnregisterPlugins() + +ForEachRegisteredStep() + +GetHooks() + +GetRegisteredParameters() + } + + class ParameterLoader { + +Load(defReg, paramReg)$ + } + + class StepLoader { + +Load(stepRegistry)$ + } + + class HookLoader { + +Load(hookRegistry)$ + } + + class PluginRegister { + +ccr_register() + } + + Application *-- DynamicLibraryManager + DynamicLibraryManager *-- "0..*" DynamicLibrary + DynamicLibrary ..> PluginRegister : resolves ccr_register + Application ..> DefinitionRegistration : unregister on destroy + DefinitionRegistration o-- "0..*" DefinitionRegistration : plugins + ParameterLoader ..> DefinitionRegistration : reads parameters + StepLoader ..> DefinitionRegistration : reads steps + HookLoader ..> DefinitionRegistration : reads hooks +``` + +**`Application`** owns the `DynamicLibraryManager` and orchestrates the full +lifecycle: CLI parsing → plugin loading → execution → cleanup. + +**`DynamicLibraryManager`** manages plugin lifetime. It holds a vector of +`DynamicLibrary` RAII wrappers. `Load()` accepts file paths or directories; +directories are scanned for platform-matching shared libraries and loaded in +sorted order. + +**`DynamicLibrary`** is a move-only RAII wrapper around `dlopen`/`LoadLibrary`. +Construction opens the library; destruction closes it. `GetSymbol(name)` +resolves a typed function pointer. + +**`DefinitionRegistration`** is a singleton holding step, hook, and parameter +registrations. Entries are keyed by `std::source_location`. Each plugin has its +own instance; the host's instance holds a `plugins` vector of pointers to +plugin instances. Query methods iterate the host's own entries first, then each +plugin's entries. + +--- + +## Data Flow + +```mermaid +flowchart TB + subgraph build["Build Phase"] + src["Plugin Source\nSTEP / HOOK / PARAMETER macros"] + reg["ccr_plugin_register\nObject Library"] + mod["MODULE Library\n.so / .dylib / .dll"] + src --> mod + reg --> mod + end + + subgraph load["Load Phase — Application::Run"] + cli["Parse --load paths"] + mgr["DynamicLibraryManager::Load"] + dir{"Directory\nor File?"} + scan["Scan & sort by\nplatform extension"] + open["dlopen / LoadLibrary\nRTLD_NOW | RTLD_GLOBAL"] + sym["dlsym ccr_register\nValidation gate"] + invoke["Invoke ccr_register\nRegisterPlugin(local)"] + pluginInit["Plugin static constructors\nalready ran on dlopen"] + singleton["Plugin DefinitionRegistration\ninstance populated"] + + cli --> mgr --> dir + dir -->|directory| scan --> open + dir -->|file| open + open --> sym --> invoke + open -.-> pluginInit --> singleton + end + + subgraph run["Execution Phase — RunCucumber"] + p1["Phase 1: ParameterLoader\nCustom parameters → ParameterRegistry"] + p2["Phase 2: StepLoader\nStep definitions → StepRegistry"] + p3["Phase 3: HookLoader\nHooks → HookRegistry"] + exec["Runtime executes\nscenarios"] + + p1 --> p2 --> p3 --> exec + end + + subgraph cleanup["Cleanup Phase — ~Application"] + unreg["DefinitionRegistration::UnregisterPlugins\nClear plugin pointer list"] + unload["DynamicLibraryManager::UnloadAll\nlibraries.clear → dlclose"] + + unreg --> unload + end + + build --> load --> run --> cleanup +``` + +The data flow has four phases: + +1. **Build** — plugin sources are compiled into MODULE shared libraries. The + `ccr_add_plugin` CMake function automatically links the `ccr_plugin_register` + object library which provides the `ccr_register` entry point. + +2. **Load** — `Application::Run` parses `--load` paths and delegates to + `DynamicLibraryManager`. Each library is opened (`dlopen` with + `RTLD_NOW | RTLD_GLOBAL` on Linux/macOS, `LoadLibrary` on Windows), + which triggers the plugin's static constructors — these register steps/hooks/ + parameters into the plugin's `DefinitionRegistration` instance. Then + `ccr_register` is called: on Linux/macOS this is a no-op (shared singleton), + on Windows it registers the plugin's separate instance with the host. + +3. **Execution** — `RunCucumber` reads from `DefinitionRegistration` in three + ordered phases: parameters first (so step expressions can reference custom + types), then steps, then hooks. Each query iterates the host's entries plus + all registered plugin entries. + +4. **Cleanup** — the `Application` destructor unregisters all plugin pointers, + then unloads the DLLs (`dlclose`). The host's own registrations remain intact. + +--- + +## Sequence Diagram — Plugin Lifecycle + +```mermaid +sequenceDiagram + actor User + participant App as Application + participant CLI as CLI11 Parser + participant DLM as DynamicLibraryManager + participant DL as DynamicLibrary + participant OS as OS (dlopen) + participant Static as Plugin Static Constructors + participant PDR as Plugin DefinitionRegistration + participant HDR as Host DefinitionRegistration + participant RC as RunCucumber + participant PL as ParameterLoader + participant SL as StepLoader + participant HL as HookLoader + + User ->> App: Run(argc, argv) + App ->> CLI: parse("--load plugin.so -- features/") + CLI -->> App: options.loadPaths = ["plugin.so"] + + rect rgb(220, 245, 220) + Note over App, HDR: Plugin Loading + App ->> DLM: Load(["plugin.so"]) + DLM ->> DL: DynamicLibrary("plugin.so") + DL ->> OS: dlopen / LoadLibrary + OS -->> Static: Static constructors execute + Static ->> PDR: Register("pattern", ...) + Static ->> PDR: Register(hookType, ...) + OS -->> DL: handle + DL ->> DL: GetSymbol("ccr_register") + DL ->> HDR: ccr_register → RegisterPlugin(PDR) + Note right of HDR: Linux/macOS: &PDR == &HDR → no-op
Windows: stores pointer to plugin instance + end + + rect rgb(220, 230, 250) + Note over App, HL: Three-Phase Registration + App ->> RC: RunCucumber(options, paramReg, ...) + + RC ->> PL: Load(defReg, paramReg) + PL ->> HDR: GetRegisteredParameters() + Note right of HDR: Iterates host + plugins + HDR -->> PL: custom parameter entries + PL -->> RC: Parameters registered + + RC ->> SL: Load(stepRegistry) + SL ->> HDR: ForEachRegisteredStep() + Note right of HDR: Iterates host + plugins + HDR -->> SL: step entries with matchers + SL -->> RC: Steps registered + + RC ->> HL: Load(hookRegistry) + HL ->> HDR: GetHooks() + Note right of HDR: Iterates host + plugins + HDR -->> HL: hook entries + HL -->> RC: Hooks registered + end + + rect rgb(250, 230, 230) + Note over App, HDR: Cleanup + App ->> HDR: UnregisterPlugins() + Note right of HDR: Clear plugin pointer list + App ->> DLM: UnloadAll() + DLM ->> DL: ~DynamicLibrary() + DL ->> OS: dlclose(handle) + end +``` + +--- + +## Platform Differences + +The plugin-list model adapts to each platform's shared library semantics: + +```mermaid +flowchart LR + subgraph linux["Linux / macOS"] + direction TB + lhost["Host Process"] + ldr["DefinitionRegistration\n(single shared instance)"] + lplugin["Plugin .so / .dylib"] + lstatic["Static constructors\nregister directly into\nshared instance"] + lccr["ccr_register:\nRegisterPlugin(self)\n→ self-guard skips"] + + lhost --> ldr + lplugin -.->|"RTLD_GLOBAL\nsymbols from host"| ldr + lplugin --> lstatic --> ldr + lplugin --> lccr + end + + subgraph windows["Windows"] + direction TB + whost["Host Process"] + whdr["Host DefinitionRegistration\nplugins: [&A, &B]"] + wpluginA["Plugin A .dll"] + wpdrA["Plugin A\nDefinitionRegistration"] + wpluginB["Plugin B .dll"] + wpdrB["Plugin B\nDefinitionRegistration"] + wccr["ccr_register:\nRegisterPlugin(local)\n→ host stores pointer"] + + whost --> whdr + wpluginA --> wpdrA + wpluginB --> wpdrB + whdr -.->|"iterates"| wpdrA + whdr -.->|"iterates"| wpdrB + wpluginA --> wccr + wpluginB --> wccr + end +``` + +| Aspect | Linux / macOS | Windows | +|--------|---------------|---------| +| Symbol resolution | `RTLD_GLOBAL` — plugin uses host symbols | Static link — plugin has own copy of `cucumber_cpp` | +| `DefinitionRegistration::Instance()` | Same address in host and plugin | Different instance per DLL | +| Static constructors | Register into the shared (host) instance | Register into plugin-local instance | +| `ccr_register` effect | No-op (self-registration guard) | Stores plugin instance pointer in host | +| `ConverterTypeMap` | Single shared instance | Separate per DLL (step executes in its own DLL context) | +| Plugin list (`plugins`) | Empty — all entries already in host | One entry per loaded plugin | +| CMake linking | No link (symbols from host via `-rdynamic`) | `target_link_libraries(plugin PRIVATE cucumber_cpp)` | +| macOS specifics | `-undefined dynamic_lookup` suppresses linker errors | — | + +--- + +## Plugin Registration Model + +Each plugin has its own `DefinitionRegistration` singleton. The host's instance +maintains a list of plugin instance pointers. + +```mermaid +flowchart TD + dest["~Application()"] + check{"dynamicLibraryManager\n.GetLoadedLibraries()\nempty?"} + skip["No cleanup needed"] + unreg["UnregisterPlugins()\nClear plugin pointer list"] + unload["UnloadAll()\nlibraries.clear() → dlclose"] + done["Host registrations intact\nPlugin DLLs unloaded"] + + dest --> check + check -->|yes| skip + check -->|no| unreg --> unload --> done +``` + +**How it works:** + +1. Plugins are loaded and their static constructors register steps/hooks/parameters + into the plugin's own `DefinitionRegistration::Instance()`. + +2. `ccr_register` is called with a pointer to the host's `DefinitionRegistration`. + It calls `host.RegisterPlugin(local)`. A self-registration guard (`&plugin == this`) + skips registration on Linux/macOS where RTLD_GLOBAL makes both point to the + same singleton. + +3. Query methods (`ForEachRegisteredStep`, `GetHooks`, `GetRegisteredParameters`, + `LoadIds`) iterate the host's own entries, then each plugin's entries. + +4. On destruction, `UnregisterPlugins()` clears the pointer list, then + `ConverterRegistry::RestoreSnapshot()` restores the converter map to its + pre-plugin state, before `UnloadAll()` closes the shared libraries + (preventing dangling pointers). + +**Consequence**: Multiple sequential `Application` instances in the same process +will correctly share statically-linked definitions while each getting a fresh +set of plugin-loaded definitions. + +--- + +## ABI Versioning + +The `PluginHostContext` struct passed to `ccr_register` includes version +metadata to guard against host/plugin version skew: + +```cpp +struct PluginHostContext +{ + uint32_t abi_version; // pluginAbiVersion constant + uint32_t struct_size; // sizeof(PluginHostContext) at build time + void* registration; + void* stopwatch; + void* timestampGenerator; + void* converterMap; +}; +``` + +- **`abi_version`** — compared by the plugin against its own compiled-in + `pluginAbiVersion`. A mismatch causes `ccr_register` to return immediately + without registering, preventing undefined behaviour from incompatible structs. +- **`struct_size`** — allows future extensions to append fields to + `PluginHostContext` while old plugins safely ignore them. + +--- + +## Snapshot / Restore Cleanup + +Before loading plugins, the host takes snapshots of mutable global state: + +```cpp +support::DefinitionRegistration::Instance().TakeSnapshot(); +cucumber_expression::ConverterRegistry::TakeSnapshot(); +dynamicLibraryManager.Load(options.loadPaths); +``` + +On destruction, rather than clearing all state (which would destroy +statically-registered converters), the host restores to the snapshot: + +```cpp +support::DefinitionRegistration::Instance().UnregisterPlugins(); +cucumber_expression::ConverterRegistry::RestoreSnapshot(); +dynamicLibraryManager.UnloadAll(); +``` + +This ensures that converters and definitions registered by the statically-linked +test code survive across Application instances, while plugin-added entries are +properly removed. + +--- + +## Duplicate Plugin Guard + +`DefinitionRegistration::RegisterPlugin` includes a duplicate-pointer guard: + +```cpp +if (std::find(plugins.begin(), plugins.end(), &plugin) != plugins.end()) + return; +``` + +This prevents the same plugin from being registered twice if `--load` lists +the same path multiple times or if a directory scan includes a plugin that +was also explicitly specified. diff --git a/cucumber_cpp/library/plugin/ParameterLoader.cpp b/cucumber_cpp/library/plugin/ParameterLoader.cpp new file mode 100644 index 00000000..0b03478a --- /dev/null +++ b/cucumber_cpp/library/plugin/ParameterLoader.cpp @@ -0,0 +1,27 @@ +#include "cucumber_cpp/library/plugin/ParameterLoader.hpp" +#include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/support/DefinitionRegistration.hpp" +#include + +namespace cucumber_cpp::library::plugin +{ + void ParameterLoader::Load(support::DefinitionRegistration& registration, cucumber_expression::ParameterRegistry& parameterRegistry) + { + const auto& existingParameters = parameterRegistry.GetParameters(); + + for (const auto& parameter : registration.GetRegisteredParameters()) + { + if (!existingParameters.contains(parameter.params.name)) + { + parameterRegistry.AddParameter( + cucumber_expression::ParameterType{ + .name = parameter.params.name, + .regex = { std::string(parameter.params.regex) }, + .isBuiltin = false, + .useForSnippets = parameter.params.useForSnippets, + .location = parameter.location, + }); + } + } + } +} diff --git a/cucumber_cpp/library/plugin/ParameterLoader.hpp b/cucumber_cpp/library/plugin/ParameterLoader.hpp new file mode 100644 index 00000000..99ac2dfa --- /dev/null +++ b/cucumber_cpp/library/plugin/ParameterLoader.hpp @@ -0,0 +1,15 @@ +#ifndef PLUGIN_PARAMETER_LOADER_HPP +#define PLUGIN_PARAMETER_LOADER_HPP + +#include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/support/DefinitionRegistration.hpp" + +namespace cucumber_cpp::library::plugin +{ + struct ParameterLoader + { + static void Load(support::DefinitionRegistration& registration, cucumber_expression::ParameterRegistry& parameterRegistry); + }; +} + +#endif diff --git a/cucumber_cpp/library/plugin/PluginExport.hpp b/cucumber_cpp/library/plugin/PluginExport.hpp new file mode 100644 index 00000000..0746aef5 --- /dev/null +++ b/cucumber_cpp/library/plugin/PluginExport.hpp @@ -0,0 +1,29 @@ +#ifndef PLUGIN_PLUGIN_EXPORT_HPP +#define PLUGIN_PLUGIN_EXPORT_HPP + +#include + +#if defined(_WIN32) || defined(__CYGWIN__) +#define CCR_EXPORT __declspec(dllexport) +#else +#define CCR_EXPORT __attribute__((visibility("default"))) +#endif + +namespace cucumber_cpp::library::plugin +{ + constexpr uint32_t pluginAbiVersion = 1; + + struct PluginHostContext + { + uint32_t abiVersion; + uint32_t structSize; + void* registration; + void* stopwatch; + void* timestampGenerator; + void* converterMap; + }; + + using CcrRegisterFn = void (*)(PluginHostContext*); +} + +#endif diff --git a/cucumber_cpp/library/plugin/PluginRegister.cpp b/cucumber_cpp/library/plugin/PluginRegister.cpp new file mode 100644 index 00000000..9b67e06f --- /dev/null +++ b/cucumber_cpp/library/plugin/PluginRegister.cpp @@ -0,0 +1,47 @@ +#include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" +#include "cucumber_cpp/library/plugin/PluginExport.hpp" +#include "cucumber_cpp/library/support/DefinitionRegistration.hpp" +#include "cucumber_cpp/library/util/Duration.hpp" +#include "cucumber_cpp/library/util/Timestamp.hpp" + +extern "C" CCR_EXPORT void ccr_register(cucumber_cpp::library::plugin::PluginHostContext* context) +{ + if (context == nullptr || context->registration == nullptr) + return; + + if (context->abiVersion != cucumber_cpp::library::plugin::pluginAbiVersion) + return; + + auto& host = *static_cast(context->registration); + host.RegisterPlugin(cucumber_cpp::library::support::DefinitionRegistration::Instance()); + + if (context->stopwatch != nullptr) + cucumber_cpp::library::util::Stopwatch::SetInstance(*static_cast(context->stopwatch)); + + if (context->timestampGenerator != nullptr) + cucumber_cpp::library::util::TimestampGenerator::SetInstance(*static_cast(context->timestampGenerator)); + + if (context->converterMap != nullptr) + { + auto* hostMap = static_cast(context->converterMap); + auto& local = cucumber_cpp::library::cucumber_expression::ConverterRegistry::LocalInstance(); + + // On Linux (ELF unique symbols), host and plugin share the same + // static map, so migration is unnecessary and clearing would + // destroy the host's converters. Only migrate on platforms where + // they are separate (e.g. Windows DLLs). + if (&local != hostMap) + { + // Migrate any converters this plugin already registered into its + // local map (built-ins constructed during plugin startup) into the + // host-owned map, without overwriting existing host entries. + for (auto& [name, fn] : local) + hostMap->try_emplace(name, std::move(fn)); + local.clear(); + + // Redirect all future registrations and lookups in this plugin to the + // host-owned converter map so host and plugin share the same storage. + cucumber_cpp::library::cucumber_expression::ConverterRegistry::SetInstance(hostMap); + } + } +} diff --git a/cucumber_cpp/library/plugin/StepLoader.cpp b/cucumber_cpp/library/plugin/StepLoader.cpp new file mode 100644 index 00000000..8531cd8c --- /dev/null +++ b/cucumber_cpp/library/plugin/StepLoader.cpp @@ -0,0 +1,10 @@ +#include "cucumber_cpp/library/plugin/StepLoader.hpp" +#include "cucumber_cpp/library/support/StepRegistry.hpp" + +namespace cucumber_cpp::library::plugin +{ + void StepLoader::Load(support::StepRegistry& stepRegistry) + { + stepRegistry.LoadSteps(); + } +} diff --git a/cucumber_cpp/library/plugin/StepLoader.hpp b/cucumber_cpp/library/plugin/StepLoader.hpp new file mode 100644 index 00000000..06eda5ba --- /dev/null +++ b/cucumber_cpp/library/plugin/StepLoader.hpp @@ -0,0 +1,14 @@ +#ifndef PLUGIN_STEP_LOADER_HPP +#define PLUGIN_STEP_LOADER_HPP + +#include "cucumber_cpp/library/support/StepRegistry.hpp" + +namespace cucumber_cpp::library::plugin +{ + struct StepLoader + { + static void Load(support::StepRegistry& stepRegistry); + }; +} + +#endif diff --git a/cucumber_cpp/library/runtime/NestedTestCaseRunner.cpp b/cucumber_cpp/library/runtime/NestedTestCaseRunner.cpp index 924ea075..5aba3bbe 100644 --- a/cucumber_cpp/library/runtime/NestedTestCaseRunner.cpp +++ b/cucumber_cpp/library/runtime/NestedTestCaseRunner.cpp @@ -107,9 +107,9 @@ namespace cucumber_cpp::library::runtime { const auto& definition = stepDefinitions.front(); NestedTestCaseRunner nestedTestCaseRunner{ nesting, supportCodeLibrary, broadcaster, testCaseContext, testStepStarted }; - const util::BodyFactory bodyFactory = [&nestedTestCaseRunner, &definition, &broadcaster, &testCaseContext, &testStepStarted, &dataTable, &docString] + const util::BodyFactory bodyFactory = [&nestedTestCaseRunner, &definition, &broadcaster, &testCaseContext, &testStepStarted, &dataTable, &docString](util::TestStepResult& testStepResult) { - return definition.factory(nestedTestCaseRunner, broadcaster, testCaseContext, testStepStarted, util::TransformTable(dataTable), util::TransformDocString(docString)); + return definition.factory(testStepResult, nestedTestCaseRunner, broadcaster, testCaseContext, testStepStarted, util::TransformTable(dataTable), util::TransformDocString(docString)); }; Invoke(nesting, step, bodyFactory, testStep.step_match_arguments_lists->front()); } diff --git a/cucumber_cpp/library/runtime/TestCaseRunner.cpp b/cucumber_cpp/library/runtime/TestCaseRunner.cpp index f7756ac5..e9201c2e 100644 --- a/cucumber_cpp/library/runtime/TestCaseRunner.cpp +++ b/cucumber_cpp/library/runtime/TestCaseRunner.cpp @@ -27,6 +27,7 @@ #include "cucumber_cpp/library/util/GetWorstTestStepResult.hpp" #include "cucumber_cpp/library/util/HookData.hpp" #include "cucumber_cpp/library/util/ScenarioInfo.hpp" +#include "cucumber_cpp/library/util/TestStepResult.hpp" #include "cucumber_cpp/library/util/Timestamp.hpp" #include "cucumber_cpp/library/util/TransformDocString.hpp" #include "cucumber_cpp/library/util/TransformPickleTag.hpp" @@ -165,9 +166,9 @@ namespace cucumber_cpp::library::runtime .status = cucumber::messages::test_step_result_status::SKIPPED, }; - const util::BodyFactory bodyFactory = [&hookDefinition, this, &testCaseContext, &testStepStarted, hasError] + const util::BodyFactory bodyFactory = [&hookDefinition, this, &testCaseContext, &testStepStarted, hasError](util::TestStepResult& testStepResult) { - return hookDefinition.factory(broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted), MakeScenarioInfo(pickle), hasError); + return hookDefinition.factory(testStepResult, broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted), MakeScenarioInfo(pickle), hasError); }; return InvokeStep(bodyFactory, {}); @@ -182,9 +183,9 @@ namespace cucumber_cpp::library::runtime for (const auto& id : ids) { const auto& definition = supportCodeLibrary.hookRegistry.GetDefinitionById(id); - const auto bodyFactory = [&definition, this, &testCaseContext, &testStepStarted] + const auto bodyFactory = [&definition, this, &testCaseContext, &testStepStarted](util::TestStepResult& testStepResult) { - return definition.factory(broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted), MakeScenarioInfo(pickle), false); + return definition.factory(testStepResult, broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted), MakeScenarioInfo(pickle), false); }; results.emplace_back(InvokeStep(bodyFactory, {})); @@ -240,9 +241,9 @@ namespace cucumber_cpp::library::runtime NestedTestCaseRunner nestedTestCaseRunner{ 0, supportCodeLibrary, broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted) }; - const util::BodyFactory bodyFactory = [this, &definition, &nestedTestCaseRunner, &testCaseContext, &testStepStarted, &dataTable, &docString] + const util::BodyFactory bodyFactory = [this, &definition, &nestedTestCaseRunner, &testCaseContext, &testStepStarted, &dataTable, &docString](util::TestStepResult& testStepResult) { - return definition.factory(nestedTestCaseRunner, broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted), util::TransformTable(dataTable), util::TransformDocString(docString)); + return definition.factory(testStepResult, nestedTestCaseRunner, broadcaster, testCaseContext, util::TransformTestStepStarted(testStepStarted), util::TransformTable(dataTable), util::TransformDocString(docString)); }; stepResults.push_back(InvokeStep(bodyFactory, testStep.step_match_arguments_lists->front())); diff --git a/cucumber_cpp/library/runtime/Worker.cpp b/cucumber_cpp/library/runtime/Worker.cpp index b1a6e999..0d871b3c 100644 --- a/cucumber_cpp/library/runtime/Worker.cpp +++ b/cucumber_cpp/library/runtime/Worker.cpp @@ -193,9 +193,9 @@ namespace cucumber_cpp::library::runtime if (!options.dryRun) { - const util::BodyFactory bodyFactory = [&definition, this, &context, &testRunHookStarted] + const util::BodyFactory bodyFactory = [&definition, this, &context, &testRunHookStarted](util::TestStepResult& testStepResult) { - return definition.factory(broadcaster, context, util::TransformTestRunHookStarted(testRunHookStarted), std::nullopt, false); + return definition.factory(testStepResult, broadcaster, context, util::TransformTestRunHookStarted(testRunHookStarted), std::nullopt, false); }; result = util::TransformTestStepResult(util::ConstructAndExecute(bodyFactory)); diff --git a/cucumber_cpp/library/support/DefinitionRegistration.cpp b/cucumber_cpp/library/support/DefinitionRegistration.cpp index f331c71b..828fe1c8 100644 --- a/cucumber_cpp/library/support/DefinitionRegistration.cpp +++ b/cucumber_cpp/library/support/DefinitionRegistration.cpp @@ -7,15 +7,16 @@ #include "cucumber_cpp/library/util/HookData.hpp" #include "cucumber_cpp/library/util/HookFactory.hpp" #include "cucumber_cpp/library/util/StepFactory.hpp" +#include #include #include #include -#include #include #include #include #include #include +#include #include #include @@ -32,6 +33,33 @@ namespace cucumber_cpp::library::support return instance; } + void DefinitionRegistration::RegisterPlugin(DefinitionRegistration& plugin) + { + if (&plugin == this) + return; + + if (std::find(plugins.begin(), plugins.end(), &plugin) != plugins.end()) + return; + + plugins.push_back(&plugin); + } + + void DefinitionRegistration::UnregisterPlugins() + { + plugins.clear(); + registry.clear(); + customParameters.clear(); + } + + void DefinitionRegistration::TakeSnapshot() + { + if (staticRegistry.empty() && staticCustomParameters.empty()) + { + staticRegistry = std::move(registry); + staticCustomParameters = std::move(customParameters); + } + } + void DefinitionRegistration::LoadIds(cucumber::gherkin::id_generator_ptr idGenerator) { const auto assignGenerator = [&idGenerator](auto& entry) @@ -39,26 +67,46 @@ namespace cucumber_cpp::library::support entry.id = idGenerator->next_id(); }; + for (auto& [key, item] : staticRegistry) + std::visit(assignGenerator, item); + for (auto& [key, item] : registry) std::visit(assignGenerator, item); + + for (auto* plugin : plugins) + for (auto& [key, item] : plugin->registry) + std::visit(assignGenerator, item); } std::vector DefinitionRegistration::GetHooks() { - auto allSteps = registry | std::views::values | std::views::filter([](const Entry& entry) - { - return std::holds_alternative(entry); - }) | - std::views::transform([](const Entry& entry) - { - return std::get(entry); - }); - return { allSteps.begin(), allSteps.end() }; + std::vector result; + + auto collectHooks = [&result](auto& reg) + { + for (auto& [key, entry] : reg) + if (std::holds_alternative(entry)) + result.push_back(std::get(entry)); + }; + + collectHooks(staticRegistry); + collectHooks(registry); + + for (auto* plugin : plugins) + collectHooks(plugin->registry); + + return result; } - const std::set>& DefinitionRegistration::GetRegisteredParameters() const + std::set> DefinitionRegistration::GetRegisteredParameters() const { - return customParameters; + auto result = staticCustomParameters; + result.insert(customParameters.begin(), customParameters.end()); + + for (const auto* plugin : plugins) + result.insert(plugin->customParameters.begin(), plugin->customParameters.end()); + + return result; } std::size_t DefinitionRegistration::Register(Hook hook, util::HookType hookType, util::HookFactory factory, std::source_location sourceLocation) diff --git a/cucumber_cpp/library/support/DefinitionRegistration.hpp b/cucumber_cpp/library/support/DefinitionRegistration.hpp index 6f1b9a33..d94778a5 100644 --- a/cucumber_cpp/library/support/DefinitionRegistration.hpp +++ b/cucumber_cpp/library/support/DefinitionRegistration.hpp @@ -29,6 +29,11 @@ namespace cucumber_cpp::library::support public: static DefinitionRegistration& Instance(); + void RegisterPlugin(DefinitionRegistration& plugin); + void UnregisterPlugins(); + + void TakeSnapshot(); + void LoadIds(cucumber::gherkin::id_generator_ptr idGenerator); template @@ -36,7 +41,7 @@ namespace cucumber_cpp::library::support std::vector GetHooks(); - const std::set>& GetRegisteredParameters() const; + [[nodiscard]] std::set> GetRegisteredParameters() const; template static std::size_t Register(Hook hook, util::HookType hookType, std::source_location sourceLocation = std::source_location::current()); @@ -57,6 +62,11 @@ namespace cucumber_cpp::library::support std::map registry; std::set> customParameters; + + std::vector plugins; + + std::map staticRegistry; + std::set> staticCustomParameters; }; ////////////////////////// @@ -66,19 +76,28 @@ namespace cucumber_cpp::library::support template void DefinitionRegistration::ForEachRegisteredStep(const T& func) { - auto allSteps = registry | - std::views::values | - std::views::filter([](const Entry& entry) - { - return std::holds_alternative(entry); - }) | - std::views::transform([](const Entry& entry) - { - return std::get(entry); - }); - - for (const auto& step : allSteps) - func(step); + auto forEachStep = [&func](auto& reg) + { + auto allSteps = reg | + std::views::values | + std::views::filter([](const Entry& entry) + { + return std::holds_alternative(entry); + }) | + std::views::transform([](const Entry& entry) + { + return std::get(entry); + }); + + for (const auto& step : allSteps) + func(step); + }; + + forEachStep(staticRegistry); + forEachStep(registry); + + for (auto* plugin : plugins) + forEachStep(plugin->registry); } template diff --git a/cucumber_cpp/library/util/Body.cpp b/cucumber_cpp/library/util/Body.cpp index 943dd8c4..f7500711 100644 --- a/cucumber_cpp/library/util/Body.cpp +++ b/cucumber_cpp/library/util/Body.cpp @@ -13,49 +13,48 @@ namespace cucumber_cpp::library::util { - namespace + struct CucumberResultReporter : public testing::ScopedFakeTestPartResultReporter { + explicit CucumberResultReporter(util::TestStepResult& testStepResult); - struct CucumberResultReporter : public testing::ScopedFakeTestPartResultReporter - { - explicit CucumberResultReporter(util::TestStepResult& testStepResult) - : testing::ScopedFakeTestPartResultReporter{ nullptr } - , testStepResult{ testStepResult } - { - } + void ReportTestPartResult(const testing::TestPartResult& testPartResult) override; - void ReportTestPartResult(const testing::TestPartResult& testPartResult) override - { - if (testPartResult.failed()) - { - testStepResult.status = util::TestStepResultStatus::FAILED; + private: + util::TestStepResult& testStepResult; + }; - auto fileName = std::filesystem::relative(testPartResult.file_name(), std::filesystem::current_path()).string(); + CucumberResultReporter::CucumberResultReporter(util::TestStepResult& testStepResult) + : testing::ScopedFakeTestPartResultReporter{ nullptr } + , testStepResult{ testStepResult } + { + } + + void CucumberResultReporter::ReportTestPartResult(const testing::TestPartResult& testPartResult) + { + if (testPartResult.failed()) + { + testStepResult.status = util::TestStepResultStatus::FAILED; - if (testStepResult.message) - testStepResult.message = fmt::format("{}\n{}:{}: Failure\n{}", testStepResult.message.value(), fileName, testPartResult.line_number(), testPartResult.message()); - else - testStepResult.message = fmt::format("{}:{}: Failure\n{}", fileName, testPartResult.line_number(), testPartResult.message()); - } + auto fileName = std::filesystem::relative(testPartResult.file_name(), std::filesystem::current_path()).string(); - if (testPartResult.fatally_failed()) - throw FatalError{ testPartResult.message() }; - } + if (testStepResult.message) + testStepResult.message = fmt::format("{}\n{}:{}: Failure\n{}", testStepResult.message.value(), fileName, testPartResult.line_number(), testPartResult.message()); + else + testStepResult.message = fmt::format("{}:{}: Failure\n{}", fileName, testPartResult.line_number(), testPartResult.message()); + } - private: - util::TestStepResult& testStepResult; - }; + if (testPartResult.fatally_failed()) + throw FatalError{ testPartResult.message() }; } - TestStepResult ConstructAndExecute(const std::function()>& bodyFactory, const ExecuteArgs& args) + TestStepResult ConstructAndExecute(const BodyFactory& bodyFactory, const ExecuteArgs& args) { const auto startTime = Stopwatch::Instance().Start(); TestStepResult testStepResult{ .status = TestStepResultStatus::PASSED }; - CucumberResultReporter reportListener{ testStepResult }; try { - bodyFactory()->Execute(args); + bodyFactory(testStepResult)->Execute(args); } catch (...) { @@ -71,4 +70,10 @@ namespace cucumber_cpp::library::util return testStepResult; } + + Body::Body(TestStepResult& testStepResult) + : reportListener{ std::make_unique(testStepResult) } + {} + + Body::~Body() = default; } diff --git a/cucumber_cpp/library/util/Body.hpp b/cucumber_cpp/library/util/Body.hpp index 130ba47a..4667fedb 100644 --- a/cucumber_cpp/library/util/Body.hpp +++ b/cucumber_cpp/library/util/Body.hpp @@ -3,6 +3,7 @@ #include "cucumber_cpp/library/cucumber_expression/ParameterRegistry.hpp" #include "cucumber_cpp/library/util/TestStepResult.hpp" +#include "gtest/gtest.h" #include #include #include @@ -14,6 +15,7 @@ namespace cucumber_cpp::library::util { + struct CucumberResultReporter; struct FatalError : std::runtime_error { @@ -54,17 +56,20 @@ namespace cucumber_cpp::library::util struct Body; - using BodyFactory = std::function()>; + using BodyFactory = std::function(TestStepResult&)>; TestStepResult ConstructAndExecute(const BodyFactory& bodyFactory, const ExecuteArgs& args = {}); struct Body { - virtual ~Body() = default; + explicit Body(TestStepResult& testStepResult); + virtual ~Body(); private: virtual void Execute(const ExecuteArgs& args) = 0; friend TestStepResult ConstructAndExecute(const BodyFactory& bodyFactory, const ExecuteArgs& args); + + std::unique_ptr reportListener; }; } diff --git a/cucumber_cpp/library/util/Duration.cpp b/cucumber_cpp/library/util/Duration.cpp index 8f0be59c..519ea8bc 100644 --- a/cucumber_cpp/library/util/Duration.cpp +++ b/cucumber_cpp/library/util/Duration.cpp @@ -39,6 +39,17 @@ namespace cucumber_cpp::library::util return *instance; } + void Stopwatch::SetInstance(Stopwatch& inst) + { + instance = &inst; + } + + Stopwatch::~Stopwatch() + { + if (instance == this) + instance = nullptr; + } + std::chrono::high_resolution_clock::time_point StopWatchHighResolutionClock::Start() { return std::chrono::high_resolution_clock::now(); diff --git a/cucumber_cpp/library/util/Duration.hpp b/cucumber_cpp/library/util/Duration.hpp index fb1741b0..c07b9d61 100644 --- a/cucumber_cpp/library/util/Duration.hpp +++ b/cucumber_cpp/library/util/Duration.hpp @@ -15,12 +15,11 @@ namespace cucumber_cpp::library::util struct Stopwatch { - protected: Stopwatch(); - ~Stopwatch() = default; + virtual ~Stopwatch(); - public: static Stopwatch& Instance(); + static void SetInstance(Stopwatch& inst); virtual std::chrono::high_resolution_clock::time_point Start() = 0; virtual std::chrono::nanoseconds Duration(std::chrono::high_resolution_clock::time_point timepPoint) = 0; diff --git a/cucumber_cpp/library/util/HookFactory.hpp b/cucumber_cpp/library/util/HookFactory.hpp index 3663c897..7074e864 100644 --- a/cucumber_cpp/library/util/HookFactory.hpp +++ b/cucumber_cpp/library/util/HookFactory.hpp @@ -6,18 +6,19 @@ #include "cucumber_cpp/library/util/Broadcaster.hpp" #include "cucumber_cpp/library/util/ScenarioInfo.hpp" #include "cucumber_cpp/library/util/StepOrHookStarted.hpp" +#include "cucumber_cpp/library/util/TestStepResult.hpp" #include #include #include namespace cucumber_cpp::library::util { - using HookFactory = std::unique_ptr (&)(Broadcaster& broadCaster, Context& context, StepOrHookStarted stepOrHookStarted, std::optional scenarioInfo, bool hasError); + using HookFactory = std::unique_ptr (&)(util::TestStepResult& testStepResult, Broadcaster& broadCaster, Context& context, StepOrHookStarted stepOrHookStarted, std::optional scenarioInfo, bool hasError); template - std::unique_ptr HookBodyFactory(Broadcaster& broadCaster, Context& context, StepOrHookStarted stepOrHookStarted, std::optional scenarioInfo, bool hasError) + std::unique_ptr HookBodyFactory(util::TestStepResult& testStepResult, Broadcaster& broadCaster, Context& context, StepOrHookStarted stepOrHookStarted, std::optional scenarioInfo, bool hasError) { - return std::make_unique(broadCaster, context, std::move(stepOrHookStarted), std::move(scenarioInfo), hasError); + return std::make_unique(testStepResult, broadCaster, context, std::move(stepOrHookStarted), std::move(scenarioInfo), hasError); } } diff --git a/cucumber_cpp/library/util/StepFactory.hpp b/cucumber_cpp/library/util/StepFactory.hpp index 29c87204..81819bec 100644 --- a/cucumber_cpp/library/util/StepFactory.hpp +++ b/cucumber_cpp/library/util/StepFactory.hpp @@ -7,6 +7,7 @@ #include "cucumber_cpp/library/util/DocString.hpp" #include "cucumber_cpp/library/util/StepOrHookStarted.hpp" #include "cucumber_cpp/library/util/Table.hpp" +#include "cucumber_cpp/library/util/TestStepResult.hpp" #include #include @@ -17,12 +18,12 @@ namespace cucumber_cpp::library::runtime namespace cucumber_cpp::library::util { - using StepFactory = std::unique_ptr (&)(const runtime::NestedTestCaseRunner&, Broadcaster& broadCaster, Context&, StepOrHookStarted stepOrHookStarted, const std::optional&, const std::optional&); + using StepFactory = std::unique_ptr (&)(util::TestStepResult& testStepResult, const runtime::NestedTestCaseRunner& nestedTestCaseRunner, Broadcaster& broadCaster, Context&, StepOrHookStarted stepOrHookStarted, const std::optional
&, const std::optional&); template - std::unique_ptr StepBodyFactory(const runtime::NestedTestCaseRunner& nestedTestCaseRunner, Broadcaster& broadCaster, Context& context, StepOrHookStarted stepOrHookStarted, const std::optional
& dataTable, const std::optional& docString) + std::unique_ptr StepBodyFactory(util::TestStepResult& testStepResult, const runtime::NestedTestCaseRunner& nestedTestCaseRunner, Broadcaster& broadCaster, Context& context, StepOrHookStarted stepOrHookStarted, const std::optional
& dataTable, const std::optional& docString) { - return std::make_unique(nestedTestCaseRunner, broadCaster, context, stepOrHookStarted, dataTable, docString); + return std::make_unique(testStepResult, nestedTestCaseRunner, broadCaster, context, stepOrHookStarted, dataTable, docString); } } #endif diff --git a/cucumber_cpp/library/util/Timestamp.cpp b/cucumber_cpp/library/util/Timestamp.cpp index d7cacd6d..3e7a1659 100644 --- a/cucumber_cpp/library/util/Timestamp.cpp +++ b/cucumber_cpp/library/util/Timestamp.cpp @@ -31,7 +31,8 @@ namespace cucumber_cpp::library::util TimestampGenerator::~TimestampGenerator() { - instance = nullptr; + if (instance == this) + instance = nullptr; } TimestampGenerator& TimestampGenerator::Instance() @@ -39,6 +40,11 @@ namespace cucumber_cpp::library::util return *instance; } + void TimestampGenerator::SetInstance(TimestampGenerator& inst) + { + instance = &inst; + } + std::chrono::milliseconds TimestampGeneratorSystemClock::Now() { const auto now = std::chrono::system_clock::now().time_since_epoch(); diff --git a/cucumber_cpp/library/util/Timestamp.hpp b/cucumber_cpp/library/util/Timestamp.hpp index a4ab865a..05b4b9b2 100644 --- a/cucumber_cpp/library/util/Timestamp.hpp +++ b/cucumber_cpp/library/util/Timestamp.hpp @@ -15,12 +15,11 @@ namespace cucumber_cpp::library::util struct TimestampGenerator { - protected: TimestampGenerator(); - ~TimestampGenerator(); + virtual ~TimestampGenerator(); - public: static TimestampGenerator& Instance(); + static void SetInstance(TimestampGenerator& inst); virtual std::chrono::milliseconds Now() = 0; private: diff --git a/cucumber_cpp/library/util/TransformStepMatchArgumentsList.cpp b/cucumber_cpp/library/util/TransformStepMatchArgumentsList.cpp index e7f26efb..a208be8b 100644 --- a/cucumber_cpp/library/util/TransformStepMatchArgumentsList.cpp +++ b/cucumber_cpp/library/util/TransformStepMatchArgumentsList.cpp @@ -1,4 +1,3 @@ - #include "cucumber_cpp/library/util/TransformStepMatchArgumentsList.hpp" #include "cucumber/messages/step_match_arguments_list.hpp" #include "cucumber_cpp/library/util/Body.hpp" diff --git a/cucumber_cpp/runner/CMakeLists.txt b/cucumber_cpp/runner/CMakeLists.txt index 97dafc8d..624223a9 100644 --- a/cucumber_cpp/runner/CMakeLists.txt +++ b/cucumber_cpp/runner/CMakeLists.txt @@ -7,3 +7,7 @@ target_sources(cucumber_cpp.runner PRIVATE target_link_libraries(cucumber_cpp.runner PUBLIC cucumber_cpp ) + +# Note: executables that link cucumber_cpp.runner and use --load for plugins +# must set ENABLE_EXPORTS ON to export symbols for dynamic library resolution: +# set_target_properties(my_executable PROPERTIES ENABLE_EXPORTS ON)