diff --git a/.github/workflows/Test_Thunder_Test_Support.yml b/.github/workflows/Test_Thunder_Test_Support.yml new file mode 100644 index 000000000..bf27e6cb0 --- /dev/null +++ b/.github/workflows/Test_Thunder_Test_Support.yml @@ -0,0 +1,112 @@ +name: Test Thunder Test Support Library + +permissions: + contents: read + +on: + workflow_dispatch: + push: + branches: ["master", "development/Thunder_Test_Lib"] + paths: + - 'Tests/test_support/**' + - 'Source/Thunder/**' + - 'Source/core/**' + - 'Source/plugins/**' + pull_request: + branches: ["master"] + paths: + - 'Tests/test_support/**' + - 'Source/Thunder/**' + - 'Source/core/**' + - 'Source/plugins/**' + +jobs: + SmokeTest: + runs-on: ubuntu-24.04 + + strategy: + matrix: + build_type: [Debug, Release] + + name: Smoke Test - ${{matrix.build_type}} + + steps: + - name: Install necessary packages + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 10 + command: | + sudo gem install apt-spy2 + sudo apt-spy2 fix --commit --launchpad --country=US + sudo apt-get update + sudo apt-get install -y python3-pip build-essential cmake ninja-build libusb-1.0-0-dev zlib1g-dev libssl-dev libgtest-dev + python3 -m venv venv + source venv/bin/activate + pip install jsonref + +# ----- Checkout ----- + - name: Checkout Thunder + uses: actions/checkout@v4 + with: + path: Thunder + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Checkout ThunderTools - default + if: ${{ !contains(github.event.pull_request.body, '[DependsOn=ThunderTools:') }} + uses: actions/checkout@v4 + with: + path: ThunderTools + repository: rdkcentral/ThunderTools + + - name: Regex ThunderTools + if: ${{ contains(github.event.pull_request.body, '[DependsOn=ThunderTools:') }} + id: tools + uses: AsasInnab/regex-action@v1 + with: + regex_pattern: '(?<=\[DependsOn=ThunderTools:).*(?=\])' + regex_flags: 'gim' + search_string: ${{github.event.pull_request.body}} + + - name: Checkout ThunderTools - ${{steps.tools.outputs.first_match}} + if: ${{ contains(github.event.pull_request.body, '[DependsOn=ThunderTools:') }} + uses: actions/checkout@v4 + with: + path: ThunderTools + repository: rdkcentral/ThunderTools + ref: ${{steps.tools.outputs.first_match}} + +# ----- Build ----- + - name: Build ThunderTools + run: | + source venv/bin/activate + cmake -G Ninja -S ThunderTools -B ${{matrix.build_type}}/build/ThunderTools \ + -DCMAKE_INSTALL_PREFIX="${{matrix.build_type}}/install/usr" + cmake --build ${{matrix.build_type}}/build/ThunderTools --target install + + - name: Build Thunder with test support + run: | + source venv/bin/activate + cmake -G Ninja -S Thunder -B ${{matrix.build_type}}/build/Thunder \ + -DBINDING="127.0.0.1" \ + -DCMAKE_BUILD_TYPE=${{matrix.build_type}} \ + -DCMAKE_INSTALL_PREFIX="${{matrix.build_type}}/install/usr" \ + -DCMAKE_MODULE_PATH="${PWD}/${{matrix.build_type}}/install/usr/include/WPEFramework/Modules" \ + -DPORT="0" \ + -DENABLE_CXX17=OFF \ + -DENABLE_TEST_RUNTIME=ON + cmake --build ${{matrix.build_type}}/build/Thunder --target install + +# ----- Run smoke test ----- + - name: Run smoke test + run: | + LD_LIBRARY_PATH="${{matrix.build_type}}/install/usr/lib:$LD_LIBRARY_PATH" \ + ${{matrix.build_type}}/build/Thunder/Tests/test_support/tests/thunder_test_runtime_smoke \ + --gtest_output="xml:smoke-test-results.xml" \ + --gtest_color=yes + + - name: Upload test results + uses: actions/upload-artifact@v4 + with: + name: smoke-test-results-${{matrix.build_type}} + path: smoke-test-results.xml diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 79a328ea9..c5aca4898 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -5,6 +5,7 @@ option(FILE_UNLINK_TEST "File unlink test" OFF) option(REDIRECT_TEST "Test stream redirection" OFF) option(MESSAGEBUFFER_TEST "Test message buffer" OFF) option(UNRAVELLER "reveal thread details" OFF) +option(ENABLE_TEST_RUNTIME "Build Thunder test support library for plugin integration tests" OFF) if(BUILD_TESTS) add_subdirectory(unit) @@ -40,4 +41,16 @@ endif() if(UNRAVELLER) add_subdirectory(unraveller) +endif() + +if(ENABLE_TEST_RUNTIME) + if(UNIX) + add_subdirectory(test_support) + else() + message(FATAL_ERROR + "ENABLE_TEST_RUNTIME is supported only on POSIX platforms. " + "Tests/test_support currently depends on POSIX-specific temporary " + "directory handling." + ) + endif() endif() \ No newline at end of file diff --git a/Tests/test_support/CMakeLists.txt b/Tests/test_support/CMakeLists.txt new file mode 100644 index 000000000..64aff14b4 --- /dev/null +++ b/Tests/test_support/CMakeLists.txt @@ -0,0 +1,142 @@ +# ============================================================================ +# thunder_test_support - Static library for Thunder plugin integration testing +# +# This library embeds the Thunder PluginHost server (PluginServer, Controller, +# SystemInfo, etc.) into a static archive so that GTest-based test binaries +# can spin up a real Thunder runtime in-process without launching the +# standalone Thunder daemon. +# +# Usage: +# 1. Link your test executable against thunder_test_support when the test is +# built in the same CMake build as Thunder. +# 2. Use ThunderTestRuntime::Initialize() to start the embedded server. +# 3. Call JSON-RPC or COM-RPC methods directly against loaded plugins. +# 4. Call ThunderTestRuntime::Deinitialize() when done. +# +# NOTE: PluginHost.cpp is deliberately excluded — it contains main(). +# The test binary provides its own main() via GTest. +# NOTE: The install rules below publish only the archive and public header. +# They do not export a CMake package target for external find_package() +# consumers. +# ============================================================================ + +find_package(Threads REQUIRED) + +set(THUNDER_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../Source/Thunder") + +set(THREADPOOL_COUNT "4" CACHE STRING "The number of threads in the thread pool for test runtime") + +add_library(thunder_test_support STATIC + ThunderTestRuntime.cpp + Module.cpp + ${THUNDER_SOURCE_DIR}/PluginServer.cpp + ${THUNDER_SOURCE_DIR}/Controller.cpp + ${THUNDER_SOURCE_DIR}/SystemInfo.cpp + ${THUNDER_SOURCE_DIR}/PostMortem.cpp + ${THUNDER_SOURCE_DIR}/Probe.cpp +) + +# PluginHost.cpp is deliberately excluded — it contains main(). +# The test binary supplies its own main via GTest/GMock. + +target_compile_definitions(thunder_test_support + PRIVATE + NAMESPACE=${NAMESPACE} + APPLICATION_NAME=ThunderTestRuntime + MODULE_NAME=ThunderTestRuntime + THREADPOOL_COUNT=${THREADPOOL_COUNT} + DEFAULT_SYSTEM_PATH="${SYSTEM_PATH}" + DEFAULT_PROXYSTUB_PATH="${PROXYSTUB_PATH}" +) + +target_compile_options(thunder_test_support PRIVATE -Wno-psabi) + +set_target_properties(thunder_test_support PROPERTIES + CXX_STANDARD ${CXX_STD} + CXX_STANDARD_REQUIRED YES +) + +target_link_libraries(thunder_test_support + PRIVATE + CompileSettings::CompileSettings +) + +if(EXCEPTION_CATCHING) + set_source_files_properties(${THUNDER_SOURCE_DIR}/PluginServer.cpp PROPERTIES COMPILE_FLAGS "-fexceptions") +endif() + +target_include_directories(thunder_test_support + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + PRIVATE + ${THUNDER_SOURCE_DIR} + $ + $ +) + +target_link_libraries(thunder_test_support + PUBLIC + ${NAMESPACE}Core::${NAMESPACE}Core + ${NAMESPACE}Cryptalgo::${NAMESPACE}Cryptalgo + ${NAMESPACE}COM::${NAMESPACE}COM + ${NAMESPACE}Messaging::${NAMESPACE}Messaging + ${NAMESPACE}WebSocket::${NAMESPACE}WebSocket + ${NAMESPACE}Plugins::${NAMESPACE}Plugins + ${NAMESPACE}COMProcess::${NAMESPACE}COMProcess + Threads::Threads +) + +if(WARNING_REPORTING) + target_sources(thunder_test_support PRIVATE ${THUNDER_SOURCE_DIR}/WarningReportingCategories.cpp) +endif() + +if(PROCESSCONTAINERS) + target_link_libraries(thunder_test_support + PUBLIC + ${NAMESPACE}ProcessContainers::${NAMESPACE}ProcessContainers) + target_compile_definitions(thunder_test_support + PUBLIC + PROCESSCONTAINERS_ENABLED=1) +endif() + +if(HIBERNATESUPPORT) + target_link_libraries(thunder_test_support PUBLIC + ${NAMESPACE}Hibernate::${NAMESPACE}Hibernate) + target_compile_definitions(thunder_test_support PUBLIC + HIBERNATE_SUPPORT_ENABLED=1) +endif() + +# Enforce whole-archive semantics for this target so consumers automatically pull in +# Thunder's static initializers (MODULE_NAME_DECLARATION, SERVICE_REGISTRATION). +# Use linker-specific flags only on supported platforms/toolchains. +if(APPLE) + target_link_options(thunder_test_support + INTERFACE + "-Wl,-force_load,$" + ) +elseif((CMAKE_SYSTEM_NAME STREQUAL "Linux" OR CMAKE_SYSTEM_NAME STREQUAL "Android") AND + (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")) + # Scoped to this archive only via $ — won't affect other libs. + target_link_options(thunder_test_support + INTERFACE + -Wl,--whole-archive $ -Wl,--no-whole-archive + ) +else() + message(WARNING + "Automatic whole-archive linking for thunder_test_support is not configured for " + "${CMAKE_SYSTEM_NAME} with ${CMAKE_CXX_COMPILER_ID}. Consumers may need to link " + "the archive with platform-specific whole-archive/force-load options to ensure " + "Thunder static initializers are included." + ) +endif() + +# --- Install rules --- +install(TARGETS thunder_test_support + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} +) + +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/ThunderTestRuntime.h + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/thunder_test_support +) + +add_subdirectory(tests) diff --git a/Tests/test_support/Module.cpp b/Tests/test_support/Module.cpp new file mode 100644 index 000000000..4202ae8bf --- /dev/null +++ b/Tests/test_support/Module.cpp @@ -0,0 +1,9 @@ +// Module definition for the thunder_test_support static library. +// +// MODULE_NAME is supplied by the target compile definitions so the embedded +// Source/Thunder objects and this archive-level declaration use the same +// module name. + +#include + +MODULE_NAME_ARCHIVE_DECLARATION diff --git a/Tests/test_support/ThunderTestRuntime.cpp b/Tests/test_support/ThunderTestRuntime.cpp new file mode 100644 index 000000000..b7789c1f4 --- /dev/null +++ b/Tests/test_support/ThunderTestRuntime.cpp @@ -0,0 +1,351 @@ +#include "ThunderTestRuntime.h" + +#include +#include +#include +#include + +// ========================================================================== +// ThunderTestRuntime implementation +// +// Lifecycle: Initialize() -> [run tests] -> Deinitialize() +// +// Initialize creates a unique temp directory tree under the platform temp root, +// writes a minimal Thunder config.json, parses it into PluginHost::Config, +// constructs PluginHost::Server, and calls Server::Open() which boots +// the controller and activates configured plugins. +// +// Deinitialize reverses the process: Server::Close(), cleanup temp files. +// ========================================================================== + +namespace Thunder { +namespace TestCore { + + ThunderTestRuntime::~ThunderTestRuntime() + { + Deinitialize(); + } + + bool ThunderTestRuntime::CreateDirectories() const + { + const string persistentPath = _tempDir + "persistent/"; + const string volatilePath = _tempDir + "volatile/"; + const string dataPath = _tempDir + "data/"; + + const bool created = + Core::Directory(persistentPath.c_str()).Create() && + Core::Directory(volatilePath.c_str()).Create() && + Core::Directory(dataPath.c_str()).Create(); + + if (created == false) { + TRACE_L1("ThunderTestRuntime: Failed to create temp directory tree at '%s'", _tempDir.c_str()); + } + + return created; + } + + void ThunderTestRuntime::CleanupDirectories() const + { + if (!_tempDir.empty()) { + Core::Directory(_tempDir.c_str()).Destroy(); + + // Core::Directory::Destroy() does not remove the directory if the path + // ends with a trailing separator. Normalize the path before calling it. + string path = _tempDir; + while (!path.empty() && (path.back() == '/' || path.back() == '\\')) { + path.pop_back(); + } + if (!path.empty()) { + Core::Directory(path.c_str()).Destroy(); + } + } + } + + static string TemporaryRootPath() + { + string path; + static const char* variables[] = { "TMPDIR", "TEMP", "TMP" }; + + for (const char* variable : variables) { + if ((Core::SystemInfo::GetEnvironment(variable, path) == true) && (path.empty() == false)) { + return Core::Directory::Normalize(path); + } + } + +#ifdef __WINDOWS__ + return Core::Directory::Normalize("c:/temp"); +#else + return Core::Directory::Normalize("/tmp"); +#endif + } + + static bool CreateUniqueTemporaryDirectory(string& path) + { + const string root = TemporaryRootPath(); + + if (root.empty() == true) { + return false; + } + + Core::Directory rootDirectory(root.c_str()); + if ((rootDirectory.Exists() == false) && (rootDirectory.CreatePath() == false)) { + TRACE_L1("ThunderTestRuntime: Failed to create temporary root '%s'", root.c_str()); + return false; + } + + const string processId = Core::NumberType(static_cast(Core::ProcessInfo().Id())).Text(); + const string ticks = Core::NumberType(Core::Time::Now().Ticks()).Text(); + + for (uint8_t attempt = 0; attempt < 32; ++attempt) { + const string candidate = root + "thunder_test_" + processId + '_' + ticks + '_' + Core::NumberType(attempt).Text(); + Core::Directory directory(candidate.c_str()); + + if ((directory.Exists() == false) && (directory.Create() == true)) { + path = Core::Directory::Normalize(candidate); + return true; + } + } + + TRACE_L1("ThunderTestRuntime: Failed to create unique temporary directory under '%s'", root.c_str()); + return false; + } + + string ThunderTestRuntime::BuildConfigJSON(const std::vector& plugins, + const string& systemPath, + const string& proxyStubPath) const + { + const string communicatorPath = _tempDir + "communicator|0777"; + + JsonObject config; + JsonArray pluginList; + + config["port"] = 0; + config["binding"] = "127.0.0.1"; + config["idletime"] = 180; + config["persistentpath"] = _tempDir + "persistent/"; + config["volatilepath"] = _tempDir + "volatile/"; + config["datapath"] = _tempDir + "data/"; + config["systempath"] = systemPath; + config["proxystubpath"] = proxyStubPath; + config["communicator"] = communicatorPath; + + for (const auto& plugin : plugins) { + + string serializedPluginConfig; + JsonValue pluginValue; + Core::OptionalType error; + + plugin.ToString(serializedPluginConfig); + + if ((pluginValue.FromString(serializedPluginConfig, error) == false) || (pluginValue.IsValid() == false)) { + TRACE_L1("ThunderTestRuntime: Failed to serialize configuration for plugin '%s'", plugin.Callsign.Value().c_str()); + return string(); + } + + pluginList.Add(pluginValue); + } + + config["plugins"] = pluginList; + + string json; + config.ToString(json); + + return json; + } + + uint32_t ThunderTestRuntime::Initialize(const std::vector& plugins, + const string& systemPath, + const string& proxyStubPath) + { + if (_server != nullptr) { + return Core::ERROR_ALREADY_CONNECTED; + } + + // Create a unique temp directory for this test run using Thunder Core helpers. + if (CreateUniqueTemporaryDirectory(_tempDir) == false) { + return Core::ERROR_GENERAL; + } + + if (CreateDirectories() == false) { + CleanupDirectories(); + _tempDir.clear(); + return Core::ERROR_OPENING_FAILED; + } + + // Determine system path for plugin .so files + string sysPath = systemPath.empty() + ? Core::Directory::Normalize(DEFAULT_SYSTEM_PATH) + : Core::Directory::Normalize(systemPath); + + // Determine proxy stub path + _proxyStubPath = proxyStubPath.empty() + ? Core::Directory::Normalize(DEFAULT_PROXYSTUB_PATH) + : Core::Directory::Normalize(proxyStubPath); + + // Build and write config to temp file + string configJSON = BuildConfigJSON(plugins, sysPath, _proxyStubPath); + if (configJSON.empty()) { + CleanupDirectories(); + _tempDir.clear(); + return Core::ERROR_INCOMPLETE_CONFIG; + } + _configFilePath = _tempDir + "config.json"; + + { + std::ofstream configFile(_configFilePath); + if (!configFile.is_open()) { + CleanupDirectories(); + return Core::ERROR_OPENING_FAILED; + } + configFile << configJSON; + } + + // Parse config + Core::File configFile(_configFilePath); + if (configFile.Open(true) == false) { + CleanupDirectories(); + return Core::ERROR_OPENING_FAILED; + } + + Core::OptionalType error; + _config = new PluginHost::Config(configFile, false, error); + configFile.Close(); + + if (error.IsSet()) { + delete _config; + _config = nullptr; + CleanupDirectories(); + return Core::ERROR_INCOMPLETE_CONFIG; + } + + // Initialize the messaging subsystem (must happen before Server creation, + // mirrors the MessagingInitialization() call in the real PluginHost main()). + Messaging::MessageUnit::Settings::Config messagingConfig; + uint32_t messagingResult = Messaging::MessageUnit::Instance().Open( + _config->VolatilePath(), messagingConfig, false, Messaging::MessageUnit::flush::OFF); + + if (messagingResult != Core::ERROR_NONE) { + TRACE_L1("ThunderTestRuntime: Failed to initialize messaging subsystem (0x%08X)", messagingResult); + } + + // Create and start the server + _server = new PluginHost::Server(*_config, false); + _server->Open(); + + return Core::ERROR_NONE; + } + + // Invoke a JSON-RPC method synchronously via the in-process dispatcher. + // Bypasses HTTP/WebSocket — calls IDispatcher::Invoke() directly. + // Derives the callsign from the method string (text before the first '.'). + uint32_t ThunderTestRuntime::Invoke(const string& method, + const string& params, string& response) + { + uint32_t result = Core::ERROR_ILLEGAL_STATE; + + if (_server != nullptr) { + + string callsign = Core::JSONRPC::Message::Callsign(method); + string methodName = Core::JSONRPC::Message::Method(method); + + if (callsign.empty() == true) { + result = Core::ERROR_INVALID_SIGNATURE; + } else { + + Core::ProxyType shell; + result = _server->Services().FromIdentifier(callsign, shell); + + if (result == Core::ERROR_NONE) { + + PluginHost::IDispatcher* dispatcher = shell->QueryInterface(); + + if (dispatcher == nullptr) { + result = Core::ERROR_UNAVAILABLE; + } else { + + if (MethodExists(dispatcher, callsign, methodName) == false) { + result = Core::ERROR_UNKNOWN_KEY; + } else { + result = dispatcher->Invoke(0, 0, string(), method, params, response); + } + + dispatcher->Release(); + } + } + } + } + + return result; + } + + bool ThunderTestRuntime::MethodExists(PluginHost::IDispatcher* dispatcher, + const string& callsign, + const string& methodName) const + { + bool found = false; + + JsonObject existsParams; + existsParams["method"] = methodName; + + string serializedParams; + existsParams.ToString(serializedParams); + + string existsResponse; + dispatcher->Invoke(0, 0, string(), callsign + ".exists", serializedParams, existsResponse); + + Core::JSON::Boolean available; + available.FromString(existsResponse); + found = available.Value(); + + return found; + } + + Core::ProxyType ThunderTestRuntime::GetShell(const string& callsign) + { + Core::ProxyType shell; + if (_server != nullptr) { + _server->Services().FromIdentifier(callsign, shell); + } + return shell; + } + + PluginHost::Server& ThunderTestRuntime::Server() + { + ASSERT(_server != nullptr); + return *_server; + } + + string ThunderTestRuntime::CommunicatorPath() const + { + if (_config != nullptr) { + return _config->Communicator().HostName(); + } + return string(); + } + + void ThunderTestRuntime::Deinitialize() + { + if (_server != nullptr) { + _server->Close(); + delete _server; + _server = nullptr; + } + + if (_config != nullptr) { + delete _config; + _config = nullptr; + } + + Messaging::MessageUnit::Instance().Close(); + + if (!_configFilePath.empty()) { + Core::File(_configFilePath).Destroy(); + _configFilePath.clear(); + } + + CleanupDirectories(); + _tempDir.clear(); + } + +} // namespace TestCore +} // namespace Thunder diff --git a/Tests/test_support/ThunderTestRuntime.h b/Tests/test_support/ThunderTestRuntime.h new file mode 100644 index 000000000..d0b86d9cc --- /dev/null +++ b/Tests/test_support/ThunderTestRuntime.h @@ -0,0 +1,114 @@ +#pragma once + +// ========================================================================== +// ThunderTestRuntime — Public API for in-process Thunder plugin testing +// +// Provides a lightweight wrapper around PluginHost::Server that: +// - Creates an isolated temp directory per test run +// - Generates a minimal Thunder config (JSON) on the fly +// - Boots the embedded server and activates the controller +// - Exposes helpers for JSON-RPC invocation and COM-RPC queries +// - Tears everything down cleanly on Deinitialize() +// +// Typical usage in a GTest fixture: +// +// static ThunderTestRuntime _runtime; +// +// static void SetUpTestSuite() { +// std::vector plugins = { ... }; +// _runtime.Initialize(plugins, pluginPath, proxyStubPath); +// } +// static void TearDownTestSuite() { _runtime.Deinitialize(); } +// +// TEST_F(MyTest, JsonRpc) { +// string resp; +// _runtime.Invoke("Callsign.method", params, resp); +// } +// +// TEST_F(MyTest, ComRpc) { +// auto* iface = _runtime.GetInterface("Callsign"); +// } +// ========================================================================== + +#include +#include +#include +#include +#include +#include +#include + +namespace Thunder { + +namespace PluginHost { + class Server; + class Config; +} + +namespace TestCore { + + class ThunderTestRuntime { + public: + // Reuse the real Thunder plugin configuration type. + // Key fields: Callsign, Locator, ClassName, StartupOrder, StartMode, Configuration. + using PluginConfig = Plugin::Config; + + ThunderTestRuntime() = default; + ~ThunderTestRuntime(); + + ThunderTestRuntime(const ThunderTestRuntime&) = delete; + ThunderTestRuntime& operator=(const ThunderTestRuntime&) = delete; + + // Boot the embedded Thunder server with the given plugins. + // Creates temp directories, generates config, parses it, and calls Server::Open(). + // systemPath — directory containing plugin .so files + // proxyStubPath — directory containing proxy stub .so files + // Returns Core::ERROR_NONE on success. + uint32_t Initialize(const std::vector& plugins, const string& systemPath = "", const string& proxyStubPath = ""); + + // Invoke a JSON-RPC method on a loaded plugin. + // The callsign is derived from the method string (text before the first '.'). + // Method format: "Callsign.method" (e.g. "MyPlugin.doSomething") + // Returns Core::ERROR_UNAVAILABLE if the JSON-RPC endpoint is not available. + uint32_t Invoke(const string& method, const string& params, string& response); + + // Obtain a COM-RPC interface from a loaded plugin. + // Caller must Release() the returned pointer when done. + template + INTERFACE* GetInterface(const string& callsign) + { + INTERFACE* result = nullptr; + Core::ProxyType shell = GetShell(callsign); + if (shell.IsValid()) { + result = shell->QueryInterface(); + } + return result; + } + + // Get the IShell proxy for a plugin (for activation/deactivation control). + Core::ProxyType GetShell(const string& callsign); + + // Direct access to the underlying PluginHost::Server. + PluginHost::Server& Server(); + + // Returns the UNIX domain socket path used by the communicator. + string CommunicatorPath() const; + + // Stop the server, release config, and clean up temp directories. + void Deinitialize(); + + private: + string BuildConfigJSON(const std::vector& plugins, const string& systemPath, const string& proxyStubPath) const; + bool MethodExists(PluginHost::IDispatcher* dispatcher, const string& callsign, const string& methodName) const; + bool CreateDirectories() const; + void CleanupDirectories() const; + + PluginHost::Config* _config = nullptr; + PluginHost::Server* _server = nullptr; + string _tempDir; + string _configFilePath; + string _proxyStubPath; + }; + +} // namespace TestCore +} // namespace Thunder diff --git a/Tests/test_support/tests/CMakeLists.txt b/Tests/test_support/tests/CMakeLists.txt new file mode 100644 index 000000000..d6171889a --- /dev/null +++ b/Tests/test_support/tests/CMakeLists.txt @@ -0,0 +1,17 @@ +# Smoke test for thunder_test_support +# Boots the embedded server and exercises JSON-RPC calls against the +# built-in Controller plugin. No external plugin .so files needed. + +find_package(GTest REQUIRED) + +add_executable(thunder_test_runtime_smoke + SmokeTest.cpp + Module.cpp +) + +target_link_libraries(thunder_test_runtime_smoke + PRIVATE + thunder_test_support + GTest::GTest + GTest::Main +) diff --git a/Tests/test_support/tests/Module.cpp b/Tests/test_support/tests/Module.cpp new file mode 100644 index 000000000..9fa428a64 --- /dev/null +++ b/Tests/test_support/tests/Module.cpp @@ -0,0 +1,4 @@ +#define MODULE_NAME SmokeTest +#include + +MODULE_NAME_DECLARATION(BUILD_REFERENCE) diff --git a/Tests/test_support/tests/SmokeTest.cpp b/Tests/test_support/tests/SmokeTest.cpp new file mode 100644 index 000000000..3a8bd90d8 --- /dev/null +++ b/Tests/test_support/tests/SmokeTest.cpp @@ -0,0 +1,55 @@ +// ============================================================================ +// Smoke test for thunder_test_support +// +// Verifies the library links, boots the embedded server, and exercises +// basic JSON-RPC calls against the built-in Controller plugin. +// No external plugin .so files are needed. +// ============================================================================ + +#include +#include "ThunderTestRuntime.h" + +using namespace Thunder; + +class SmokeTest : public ::testing::Test { +protected: + static TestCore::ThunderTestRuntime _runtime; + + static void SetUpTestSuite() { + // No plugins — only the built-in Controller is needed + std::vector plugins; + uint32_t result = _runtime.Initialize(plugins); + ASSERT_EQ(result, Core::ERROR_NONE) << "Failed to initialize Thunder runtime"; + } + + static void TearDownTestSuite() { + _runtime.Deinitialize(); + } +}; + +TestCore::ThunderTestRuntime SmokeTest::_runtime; + +// Verify the server booted and we can query Controller status +TEST_F(SmokeTest, ControllerStatus) { + string response; + uint32_t result = _runtime.Invoke("Controller.status", "", response); + EXPECT_EQ(result, Core::ERROR_NONE); + EXPECT_FALSE(response.empty()); + // Response should contain Controller's own entry + EXPECT_NE(response.find("Controller"), string::npos); +} + +// Verify we can query subsystems +TEST_F(SmokeTest, ControllerSubsystems) { + string response; + uint32_t result = _runtime.Invoke("Controller.subsystems", "", response); + EXPECT_EQ(result, Core::ERROR_NONE); + EXPECT_FALSE(response.empty()); +} + +// Verify we can get the IShell for Controller +TEST_F(SmokeTest, GetControllerShell) { + auto shell = _runtime.GetShell("Controller"); + ASSERT_TRUE(shell.IsValid()); + EXPECT_EQ(shell->State(), PluginHost::IShell::state::ACTIVATED); +} diff --git a/docs/ThunderTestSupport/ThunderTestSupport.md b/docs/ThunderTestSupport/ThunderTestSupport.md new file mode 100644 index 000000000..c55a9c550 --- /dev/null +++ b/docs/ThunderTestSupport/ThunderTestSupport.md @@ -0,0 +1,484 @@ +# Thunder Test Support Library + +## Overview + +The **thunder_test_support** library enables in-process integration testing of Thunder plugins without launching the Thunder daemon. It embeds the Thunder `PluginHost::Server` into a static archive (`.a`) that test binaries link against, allowing GTest-based tests to boot a real Thunder runtime, load plugins as shared libraries, and exercise both JSON-RPC and COM-RPC interfaces — all within a single process. + +### Key Properties + +| Property | Value | +|----------|-------| +| Library type | Static archive (`libthunder_test_support.a`) | +| CMake option | `ENABLE_TEST_RUNTIME=ON` (POSIX platforms only) | +| Location | `Tests/test_support/` | +| Public header | `ThunderTestRuntime.h` | +| Install paths | `${CMAKE_INSTALL_LIBDIR}/libthunder_test_support.a`, `${CMAKE_INSTALL_INCLUDEDIR}/thunder_test_support/ThunderTestRuntime.h` | +| Installed CMake package | Not exported; installation publishes the archive and header only | + +--- + +## Why This Exists + +This library was added to eliminate the need for repo-local Thunder mock layers in tests. + +Without a reusable embedded Thunder runtime, repositories typically end up recreating enough Thunder behavior locally to get code under test running at all. That usually means carrying custom mocks or shims for host services, COM link behavior, worker-pool setup, factories, module plumbing, JSON-RPC wiring, and other framework-owned pieces. + +`thunder_test_support` replaces that Thunder-specific scaffolding with a reusable in-process Thunder runtime: + +- tests link one reusable library instead of maintaining local Thunder mocks +- code under test runs against a real embedded `PluginHost::Server` +- JSON-RPC and COM-RPC calls use real Thunder plumbing rather than hand-built host/service stubs +- consumers no longer need to recreate Thunder host behavior just to exercise their code + +The goal is to stop faking Thunder itself. Tests may still keep mocks for their own non-Thunder dependencies, but the Thunder runtime layer should be provided by this library rather than rebuilt in each repository. + +--- + +## Architecture + +### Production Thunder vs Test Support + +``` +┌─────────────────────────────────────┐ ┌─────────────────────────────────────────┐ +│ PRODUCTION DEPLOYMENT │ │ TEST DEPLOYMENT │ +│ │ │ │ +│ ┌───────────────────────────────┐ │ │ ┌─────────────────────────────────┐ │ +│ │ Thunder Daemon (binary) │ │ │ │ GTest Binary (plugin_test) │ │ +│ │ │ │ │ │ │ │ +│ │ PluginHost.cpp (main) │ │ │ │ main() from gmock_main │ │ +│ │ ↓ │ │ │ │ ↓ │ │ +│ │ PluginHost::Server │ │ │ │ ThunderTestRuntime │ │ +│ │ ├── Controller │ │ │ │ ├── PluginHost::Server │ │ +│ │ ├── PluginServer │ │ │ │ │ ├── Controller │ │ +│ │ ├── SystemInfo │ │ │ │ │ ├── PluginServer │ │ +│ │ └── HTTP/WS Listener │ │ │ │ │ ├── SystemInfo │ │ +│ │ ↓ │ │ │ │ │ └── HTTP/WS Listener │ │ +│ │ Plugin .so (dynamic load) │ │ │ │ └── Plugin .so (dynamic load) │ │ +│ └───────────────────────────────┘ │ │ └─────────────────────────────────┘ │ +│ │ │ │ +│ Communication: HTTP/WS/COMRPC │ │ Communication: HTTP/WS/COMRPC available, +│ │ │ tests typically use direct in-process calls │ +└─────────────────────────────────────┘ └─────────────────────────────────────────┘ +``` + +### How It Works + +In production, Thunder runs as a standalone daemon (`PluginHost.cpp` → `main()`). The test support library takes a different approach: + +1. **Excludes `PluginHost.cpp`** — this file contains `main()` and daemon lifecycle logic (signal handling, daemonization, etc.). The test binary provides its own `main()` via GTest. + +2. **Statically links server internals** — `PluginServer.cpp`, `Controller.cpp`, `SystemInfo.cpp`, `PostMortem.cpp`, and `Probe.cpp` are compiled directly into the static library. + +3. **Wraps server lifecycle** — `ThunderTestRuntime` provides `Initialize()` and `Deinitialize()` to manage the server, replacing the daemon's startup/shutdown sequence. + +4. **Generates config on the fly** — instead of reading `/etc/Thunder/config.json`, the runtime builds a minimal JSON config using `Core::JSON` containers (`JsonObject`, `JsonArray`) and `Plugin::Config` serialization, then writes it to a temporary directory. + +5. **Still opens the listener** — `PluginHost::Server::Open()` still starts the HTTP/WebSocket listener, typically bound to `127.0.0.1` on an ephemeral port in the test runtime. + +6. **Plugins load normally** — plugin `.so` files are still loaded dynamically via `dlopen()`, exactly as in production. The `systempath` config entry points to the directory containing plugin shared libraries. + +--- + +## Files Added to Thunder + +### Directory Structure + +``` +Tests/ +├── CMakeLists.txt ← Modified: added ENABLE_TEST_RUNTIME option +└── test_support/ + ├── CMakeLists.txt ← Build definitions for the static library + ├── Module.cpp ← MODULE_NAME definition (ThunderTestRuntime) + ├── ThunderTestRuntime.h ← Public API header + ├── ThunderTestRuntime.cpp ← Implementation + └── tests/ + ├── CMakeLists.txt ← Smoke test build + ├── Module.cpp ← MODULE_NAME_DECLARATION for smoke test binary + └── SmokeTest.cpp ← Self-contained Controller smoke test +``` + +### 1. `Tests/CMakeLists.txt` (Modified) + +Two additions were made to the existing file: + +```cmake +# New option added alongside existing test options +option(ENABLE_TEST_RUNTIME "Build Thunder test support library for plugin integration tests" OFF) + +# New conditional block at the end of the file +if(ENABLE_TEST_RUNTIME) + add_subdirectory(test_support) +endif() +``` + +This follows the same pattern used by other test options (`LOADER_TEST`, `WORKERPOOL_TEST`, etc.) — off by default, enabled explicitly. + +`ENABLE_TEST_RUNTIME` is currently supported only on POSIX platforms. CMake rejects it on non-POSIX platforms instead of attempting a partial Windows build. + +### 2. `Tests/test_support/CMakeLists.txt` (New) + +Builds the `thunder_test_support` static library. Key design decisions: + +- **Source files**: Compiles `ThunderTestRuntime.cpp`, `Module.cpp`, plus Thunder server sources directly from `Source/Thunder/` (PluginServer, Controller, SystemInfo, PostMortem, Probe). +- **Excludes `PluginHost.cpp`**: This contains `main()` and would conflict with the test binary's entry point. +- **Compile definitions**: Sets `APPLICATION_NAME=ThunderTestRuntime`, `MODULE_NAME=ThunderTestRuntime`, `THREADPOOL_COUNT=4`, `DEFAULT_SYSTEM_PATH`, and `DEFAULT_PROXYSTUB_PATH` (derived from Thunder's `SYSTEM_PATH` and `PROXYSTUB_PATH` CMake variables). +- **Public dependencies**: Exposes `ThunderCore`, `ThunderCryptalgo`, `ThunderCOM`, `ThunderPlugins`, `ThunderMessaging`, `ThunderWebSocket`, `ThunderCOMProcess`, and `Threads` as PUBLIC link dependencies, so consumers automatically get all required libraries. +- **Private dependencies**: `CompileSettings` is linked PRIVATE to apply Thunder's compile flags without propagating them. +- **Conditional features**: Supports `WARNING_REPORTING`, `PROCESSCONTAINERS`, and `HIBERNATESUPPORT` when enabled. +- **Install rules**: Installs the `.a` archive to `${CMAKE_INSTALL_LIBDIR}` and the header to `${CMAKE_INSTALL_INCLUDEDIR}/thunder_test_support/`. It does not export a `thunder_test_support` CMake package/target for `find_package()` consumers. + +### 3. `Tests/test_support/Module.cpp` (New) + +Module definition required by Thunder's internal logging/tracing macros. Uses `MODULE_NAME_ARCHIVE_DECLARATION` instead of `MODULE_NAME_DECLARATION` because this is a static archive, not a standalone binary. The archive macro only defines the `MODULE_NAME` string symbol. The full declaration (`ModuleBuildRef`, `GetModuleServices`, `SetModuleServices`) is left to the consumer's own `MODULE_NAME_DECLARATION`, avoiding duplicate definitions at link time. The `thunder_test_support` target supplies `MODULE_NAME=ThunderTestRuntime` through target compile definitions, and this translation unit emits the archive-level symbol for that module name: + +```cpp +#include + +MODULE_NAME_ARCHIVE_DECLARATION +``` + +Each final consumer binary (for example, each test executable) must provide exactly one dedicated `Module.cpp` with the full declaration: + +```cpp +#define MODULE_NAME MyTestName +#include + +MODULE_NAME_DECLARATION(BUILD_REFERENCE) +``` + +This is a per-binary requirement, not a per-source-file requirement. Test sources that include `ThunderTestRuntime.h` do not need to define `MODULE_NAME` themselves as long as the executable includes one such `Module.cpp`. + +### 4. `Tests/test_support/ThunderTestRuntime.h` (New) + +Public API header. Defines the `Thunder::TestCore::ThunderTestRuntime` class: + +| Member | Description | +|--------|-------------| +| `PluginConfig` | Type alias for `Plugin::Config`. Reuses Thunder's own plugin configuration container — no custom struct needed. Key fields: `Callsign`, `Locator`, `ClassName`, `StartupOrder`, `StartMode`, `Configuration`. | +| `Initialize()` | Boots the embedded server with given plugins, system path, and proxy stub path. Initializes the messaging subsystem before server creation. | +| `Invoke()` | Calls a JSON-RPC method synchronously via in-process `IDispatcher::Invoke()`. Callsign and method are parsed using `Core::JSONRPC::Message` helpers. Checks method availability via the built-in `exists` endpoint before dispatch. | +| `GetInterface()` | Template: obtains a COM-RPC interface from a plugin via `QueryInterface()` | +| `GetShell()` | Returns the `IShell` proxy for a plugin (for activation/deactivation control) | +| `Server()` | Direct access to the underlying `PluginHost::Server` | +| `CommunicatorPath()` | Returns the UNIX domain socket path | +| `Deinitialize()` | Stops the server, closes messaging, releases config, cleans up temp directories | + +### 5. `Tests/test_support/ThunderTestRuntime.cpp` (New) + +Implementation with the following lifecycle: + +``` +Initialize() + ├── Create unique temp dir via Core helpers (process ID + ticks) + ├── Create subdirs: persistent/, volatile/, data/ + ├── Resolve system/proxystub paths from CMake defaults or caller args + ├── Build JSON config using Core::JSON + Plugin::Config serialization + ├── Write config to temp dir + ├── Parse config into PluginHost::Config + ├── Open Messaging::MessageUnit (before server creation) + ├── Construct PluginHost::Server + └── Call Server::Open() + ├── Set up security + ├── ServiceMap::Open() + ├── Activate Controller plugin + ├── Open HTTP/WebSocket listener + └── Activate auto-start plugins + +Deinitialize() + ├── Server::Close() + │ ├── Deactivate all plugins + │ └── Close connections + ├── Delete Server + ├── Delete Config + ├── Close Messaging::MessageUnit + ├── Remove config file + └── Remove temp directories +``` + +#### JSON-RPC Invocation Path + +`Invoke()` bypasses HTTP/WebSocket entirely: + +``` +Invoke(".", params, response) + ├── Core::JSONRPC::Message::Callsign(method) → callsign + ├── Core::JSONRPC::Message::Method(method) → methodName + ├── Services().FromIdentifier(callsign) → IShell proxy + ├── shell->QueryInterface() → dispatcher + ├── MethodExists(dispatcher, callsign, methodName) via built-in "exists" + └── dispatcher->Invoke(0, 0, "", method, params, response) +``` + +The method string uses unversioned format: `"Callsign.method"` (e.g. `"Controller.status"`). Callsign and method name are parsed using Thunder's `Core::JSONRPC::Message` helpers. Before dispatch, the built-in `exists` endpoint is queried to verify the method is registered — returns `Core::ERROR_UNKNOWN_KEY` if not found. + +This calls the plugin's JSON-RPC handler directly in-process, with zero network overhead. The HTTP/WebSocket listener is still started by `Server::Open()`, but the helper API does not route JSON-RPC through it. + +#### COM-RPC Interface Access + +`GetInterface()` provides direct COM-RPC access: + +``` +GetInterface("") + ├── GetShell("") → IShell proxy + └── shell->QueryInterface() → interface pointer +``` + +The caller receives a real COM-RPC interface pointer and must call `Release()` when done. + +--- + +## Build Configuration + +### CMake Options + +| Option | Default | Description | +|--------|---------|-------------| +| `ENABLE_TEST_RUNTIME` | `OFF` | Build the thunder_test_support static library on POSIX platforms | +| `BUILD_TESTS` | `OFF` | Build Thunder unit tests (independent of test runtime) | + +### Build Command + +```bash +cmake -S Thunder -B build/Thunder \ + -DCMAKE_INSTALL_PREFIX=/path/to/install \ + -DCMAKE_MODULE_PATH=/path/to/install/tools/cmake \ + -DENABLE_TEST_RUNTIME=ON + +cmake --build build/Thunder --target install -j$(nproc) +``` + +After installation, the library and header are available at: +``` +/${CMAKE_INSTALL_LIBDIR}/libthunder_test_support.a +/${CMAKE_INSTALL_INCLUDEDIR}/thunder_test_support/ThunderTestRuntime.h +``` + +No `thunder_test_support` CMake package config or exported target is installed today. External consumers should treat this as an archive-plus-header install and add any required whole-archive/force-load flags themselves. + +--- + +## Usage Guide + +### Writing a Plugin Integration Test + +#### 1. CMakeLists.txt for the test built in the same Thunder build + +```cmake +find_package(Thunder REQUIRED) +find_package(ThunderDefinitions REQUIRED) + +# Build the plugin as a shared library +add_library(ThunderMyPluginImpl SHARED + plugin/MyPlugin.cpp + plugin/MyPluginImpl.cpp + plugin/Module.cpp +) +target_link_libraries(ThunderMyPluginImpl PRIVATE + ${NAMESPACE}Plugins::${NAMESPACE}Plugins + ${NAMESPACE}Definitions::${NAMESPACE}Definitions +) + +# Build the test executable. Include one dedicated Module.cpp for this binary. +add_executable(my_plugin_test + MyPluginTest.cpp + tests/Module.cpp +) +target_link_libraries(my_plugin_test PRIVATE + thunder_test_support + gmock_main + ${NAMESPACE}Definitions::${NAMESPACE}Definitions +) +``` + +Example `tests/Module.cpp` for the test executable: + +```cpp +#define MODULE_NAME MyPluginTest +#include + +MODULE_NAME_DECLARATION(BUILD_REFERENCE) +``` + +> **Note**: The `thunder_test_support` target and its `INTERFACE` whole-archive link options are available only to targets built in the same CMake project as Thunder with `ENABLE_TEST_RUNTIME=ON`. They are not exported by the install rules. External repositories that consume the installed archive must link the `.a` file explicitly and apply the appropriate platform-specific whole-archive/force-load flags themselves. + +#### 1a. External consumer linking against the installed archive + +```cmake +find_package(Thunder REQUIRED) +find_package(ThunderDefinitions REQUIRED) + +add_executable(my_plugin_test MyPluginTest.cpp) + +target_include_directories(my_plugin_test PRIVATE + /path/to/install/${CMAKE_INSTALL_INCLUDEDIR}/thunder_test_support +) + +target_link_libraries(my_plugin_test PRIVATE + /path/to/install/${CMAKE_INSTALL_LIBDIR}/libthunder_test_support.a + gmock_main + ${NAMESPACE}Definitions::${NAMESPACE}Definitions +) + +# Add the platform-specific whole-archive / force-load option for +# libthunder_test_support.a here when linking from an external project. +``` + +#### 2. Test fixture + +```cpp +#include +#include "ThunderTestRuntime.h" +#include + +using namespace Thunder; + +class MyPluginTest : public ::testing::Test { +protected: + static TestCore::ThunderTestRuntime _runtime; + + static void SetUpTestSuite() { + std::vector plugins; + + TestCore::ThunderTestRuntime::PluginConfig cfg; + cfg.Callsign = "MyPlugin"; + cfg.Locator = "libThunderMyPluginImpl.so"; + cfg.ClassName = "MyPlugin"; + cfg.StartMode = PluginHost::IShell::startmode::ACTIVATED; + cfg.StartupOrder = 50; + cfg.Configuration = R"({"root":{"mode":"Off","locator":"libThunderMyPluginImpl.so"}})"; + plugins.push_back(cfg); + + uint32_t result = _runtime.Initialize(plugins, + "/path/to/plugins/", + "/path/to/proxystubs/"); + ASSERT_EQ(result, Core::ERROR_NONE); + } + + static void TearDownTestSuite() { + _runtime.Deinitialize(); + } +}; + +TestCore::ThunderTestRuntime MyPluginTest::_runtime; + +// JSON-RPC test +TEST_F(MyPluginTest, JsonRpcCall) { + string response; + EXPECT_EQ(Core::ERROR_NONE, + _runtime.Invoke("MyPlugin.someMethod", R"({"param":1})", response)); + // Validate response... +} + +// COM-RPC test +TEST_F(MyPluginTest, ComRpcCall) { + Exchange::IMyInterface* iface = _runtime.GetInterface("MyPlugin"); + ASSERT_NE(iface, nullptr); + // Call interface methods... + iface->Release(); +} + +// Lifecycle test +TEST_F(MyPluginTest, DeactivateReactivate) { + auto shell = _runtime.GetShell("MyPlugin"); + ASSERT_TRUE(shell.IsValid()); + EXPECT_EQ(Core::ERROR_NONE, shell->Deactivate(PluginHost::IShell::reason::REQUESTED)); + EXPECT_EQ(Core::ERROR_NONE, shell->Activate(PluginHost::IShell::reason::REQUESTED)); +} +``` + +#### 3. Running + +```bash +export LD_LIBRARY_PATH=/path/to/install/lib:/path/to/install/lib/thunder/plugins:/path/to/install/lib/thunder/proxystubs +export THUNDER_PLUGIN_PATH=/path/to/install/lib/thunder/plugins/ +export THUNDER_PROXYSTUB_PATH=/path/to/install/lib/thunder/proxystubs/ + +./my_plugin_test --gtest_color=yes +``` + +--- + +## CI Integration + +The library is designed for use in GitHub Actions workflows. A typical workflow: + +```yaml +- name: Build Thunder (with test runtime) + run: > + cmake -S Thunder -B build/Thunder + -DCMAKE_INSTALL_PREFIX=${{github.workspace}}/install/usr + -DCMAKE_MODULE_PATH=${{github.workspace}}/install/tools/cmake + -DENABLE_TEST_RUNTIME=ON + && + cmake --build build/Thunder --target install -j8 + +- name: Build plugin and tests + run: > + cmake -S my-plugin/tests -B build/tests + -DCMAKE_INSTALL_PREFIX=${{github.workspace}}/install/usr + -DCMAKE_MODULE_PATH=${{github.workspace}}/install/tools/cmake + && + cmake --build build/tests --target install -j8 + +- name: Run tests + run: | + export LD_LIBRARY_PATH=${{github.workspace}}/install/usr/lib:... + my_plugin_test --gtest_output="xml:test-results.xml" +``` + +--- + +## Design Decisions + +### Why a static library? + +A static archive ensures that all Thunder server symbols are available to the test binary at link time. Shared libraries would require careful `RPATH` management and could conflict with the installed Thunder daemon libraries. + +### Why whole-archive semantics? + +Thunder uses static initializers extensively (`MODULE_NAME_DECLARATION`, `SERVICE_REGISTRATION`, singletons). Without whole-archive semantics, the linker would discard these symbols because the test binary doesn't reference them directly. Whole-archive/force-load style linker options force all object files from the archive to be included. + +For in-tree Thunder builds, this is enforced automatically via `target_link_options(INTERFACE)` on the local CMake target, scoped to the archive using `$` so it doesn't affect other libraries on the link line. The exact linker option is platform-dependent: Apple uses `-Wl,-force_load,...`, while Linux/Android with GNU/Clang-family toolchains use `--whole-archive` / `--no-whole-archive`. Because the install rules do not export a `thunder_test_support` CMake target, external consumers of the installed `.a` file must add the appropriate platform-specific flags manually. + +### Why exclude `PluginHost.cpp`? + +`PluginHost.cpp` contains: +- `main()` — conflicts with GTest's `main()` +- Signal handler setup — unwanted in test context +- Daemonization logic — not applicable to tests +- Command-line argument parsing — replaced by programmatic config + +### Why generate config programmatically? + +- **Isolation**: Each test run gets a unique temp directory, preventing interference between parallel test runs. +- **Simplicity**: Tests declare plugins as C++ structs rather than maintaining JSON config files. +- **Portability**: Paths are resolved at runtime, making tests work across different build environments and CI systems. + +### Why port 0? + +Using port 0 in the config tells the OS to assign an available port, avoiding conflicts when multiple test processes run simultaneously. + +This is convenient for parallel runs, but it has an important caveat in the current implementation: the assigned port is not propagated back into `PluginHost::Config`, so Thunder's configured accessor/URL can still report port `0` even though the listener is actually bound to an ephemeral port. + +In practice, this is usually acceptable for `thunder_test_support` because tests typically invoke JSON-RPC in-process via `IDispatcher::Invoke()` instead of routing through the HTTP listener. However, it can be confusing when inspecting the reported accessor URL and may break plugins or tests that depend on the configured port being accurate. + +A more complete solution would be to either choose a free port before building the config, or query the bound port after `Server::Open()` and update the config/binder/accessor state accordingly. + +--- + +## Smoke Test + +A self-contained smoke test (`Tests/test_support/tests/SmokeTest.cpp`) is included with the library. It verifies that the library links, boots, and can exercise JSON-RPC against the built-in Controller plugin — no external plugin `.so` files needed. + +The smoke test is built automatically when `ENABLE_TEST_RUNTIME=ON` and GTest is available. The smoke-test executable includes one dedicated `Module.cpp` with `MODULE_NAME_DECLARATION(BUILD_REFERENCE)` — required because the library uses `MODULE_NAME_ARCHIVE_DECLARATION`. `SmokeTest.cpp` itself does not need to define `MODULE_NAME`. It covers: + +- **ControllerStatus** — calls `Controller.status` and verifies a non-empty response containing "Controller" +- **ControllerSubsystems** — calls `Controller.subsystems` and verifies a non-empty response +- **GetControllerShell** — obtains the Controller's `IShell` and verifies it is in `ACTIVATED` state + +Running: +```bash +./thunder_test_runtime_smoke --gtest_color=yes +``` + +--- \ No newline at end of file