diff --git a/.github/workflows/Test_Thunder_Test_Support.yml b/.github/workflows/Test_Thunder_Test_Support.yml new file mode 100644 index 0000000000..98e7722cb6 --- /dev/null +++ b/.github/workflows/Test_Thunder_Test_Support.yml @@ -0,0 +1,185 @@ +name: Test Thunder Test Support Library + +permissions: + contents: read + +on: + workflow_dispatch: + + pull_request: + branches: ["R5_3", "development/test-support"] + +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 + ref: R5.3.0 + + - 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}} + + - name: Checkout ThunderInterfaces - default + if: ${{ !contains(github.event.pull_request.body, '[DependsOn=ThunderInterfaces:') }} + uses: actions/checkout@v4 + with: + path: ThunderInterfaces + repository: rdkcentral/ThunderInterfaces + # TODO: Remove ref once qa_interfaces changes are merged to master + ref: development/test-support + + - name: Regex ThunderInterfaces + if: ${{ contains(github.event.pull_request.body, '[DependsOn=ThunderInterfaces:') }} + id: interfaces + uses: AsasInnab/regex-action@v1 + with: + regex_pattern: '(?<=\[DependsOn=ThunderInterfaces:).*(?=\])' + regex_flags: 'gim' + search_string: ${{github.event.pull_request.body}} + + - name: Checkout ThunderInterfaces - ${{steps.interfaces.outputs.first_match}} + if: ${{ contains(github.event.pull_request.body, '[DependsOn=ThunderInterfaces:') }} + uses: actions/checkout@v4 + with: + path: ThunderInterfaces + repository: rdkcentral/ThunderInterfaces + ref: ${{steps.interfaces.outputs.first_match}} + + - name: Checkout ThunderNanoServices - default + if: ${{ !contains(github.event.pull_request.body, '[DependsOn=ThunderNanoServices:') }} + uses: actions/checkout@v4 + with: + path: ThunderNanoServices + repository: rdkcentral/ThunderNanoServices + # TODO: Remove ref once TestPlugin changes are merged to master + ref: development/test-support + + - name: Regex ThunderNanoServices + if: ${{ contains(github.event.pull_request.body, '[DependsOn=ThunderNanoServices:') }} + id: nanoservices + uses: AsasInnab/regex-action@v1 + with: + regex_pattern: '(?<=\[DependsOn=ThunderNanoServices:).*(?=\])' + regex_flags: 'gim' + search_string: ${{github.event.pull_request.body}} + + - name: Checkout ThunderNanoServices - ${{steps.nanoservices.outputs.first_match}} + if: ${{ contains(github.event.pull_request.body, '[DependsOn=ThunderNanoServices:') }} + uses: actions/checkout@v4 + with: + path: ThunderNanoServices + repository: rdkcentral/ThunderNanoServices + ref: ${{steps.nanoservices.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/Thunder/Modules" \ + -DPORT="0" \ + -DENABLE_TEST_RUNTIME=ON + cmake --build ${{matrix.build_type}}/build/Thunder --target install + + - name: Build ThunderInterfaces + run: | + source venv/bin/activate + cmake -G Ninja -S ThunderInterfaces -B ${{matrix.build_type}}/build/ThunderInterfaces \ + -DCMAKE_BUILD_TYPE=${{matrix.build_type}} \ + -DCMAKE_INSTALL_PREFIX="${{matrix.build_type}}/install/usr" \ + -DCMAKE_MODULE_PATH="${PWD}/${{matrix.build_type}}/install/usr/include/Thunder/Modules" \ + -DCMAKE_PREFIX_PATH="${PWD}/${{matrix.build_type}}/install/usr" + cmake --build ${{matrix.build_type}}/build/ThunderInterfaces --target install + + - name: Build ThunderNanoServices TestPlugin + test + run: | + source venv/bin/activate + cmake -G Ninja -S ThunderNanoServices/tests -B ${{matrix.build_type}}/build/ThunderNanoServicesTests \ + -DCMAKE_BUILD_TYPE=${{matrix.build_type}} \ + -DCMAKE_INSTALL_PREFIX="${{matrix.build_type}}/install/usr" \ + -DCMAKE_MODULE_PATH="${PWD}/${{matrix.build_type}}/install/usr/include/Thunder/Modules" \ + -DCMAKE_PREFIX_PATH="${PWD}/${{matrix.build_type}}/install/usr" \ + -DPLUGIN_TESTPLUGIN=ON \ + -DTEST_PLUGIN_PATH="${PWD}/${{matrix.build_type}}/install/usr/lib/thunder/plugins" + cmake --build ${{matrix.build_type}}/build/ThunderNanoServicesTests --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 + +# ----- Run plugin test ----- + - name: Run plugin test (COM-RPC + JSON-RPC + events) + run: | + LD_LIBRARY_PATH="${{matrix.build_type}}/install/usr/lib:${PWD}/${{matrix.build_type}}/install/usr/lib/thunder/plugins:$LD_LIBRARY_PATH" \ + ${{matrix.build_type}}/build/ThunderNanoServicesTests/TestPlugin/test/thunder_testplugin_test \ + --gtest_output="xml:plugin-test-results.xml" \ + --gtest_color=yes + + - name: Upload test results + uses: actions/upload-artifact@v4 + with: + name: test-results-${{matrix.build_type}} + path: | + smoke-test-results.xml + plugin-test-results.xml diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 79a328ea9c..220f102291 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 the in-process Thunder test runtime library" OFF) if(BUILD_TESTS) add_subdirectory(unit) @@ -40,4 +41,8 @@ endif() if(UNRAVELLER) add_subdirectory(unraveller) +endif() + +if(ENABLE_TEST_RUNTIME) + add_subdirectory(test_support) 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 0000000000..5e6a5149b6 --- /dev/null +++ b/Tests/test_support/CMakeLists.txt @@ -0,0 +1,184 @@ +# ========================================================================== +# thunder_test_support — static library for in-process Thunder plugin testing +# +# Compiles the test runtime together with the subset of Source/Thunder +# objects needed to host an embedded PluginHost::Server. The resulting +# archive is linked via whole-archive so that MODULE_NAME_DECLARATION +# statics are not dropped by the linker. +# ========================================================================== + +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") + +set(TARGET thunder_test_support) + +add_library(${TARGET} 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 +) + +target_include_directories(${TARGET} + PUBLIC + $ + $ + PRIVATE + ${THUNDER_SOURCE_DIR} + $ + $ +) + +target_compile_definitions(${TARGET} + 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(${TARGET} PRIVATE -Wno-psabi) + +set_target_properties(${TARGET} PROPERTIES + CXX_STANDARD 11 + CXX_STANDARD_REQUIRED YES +) + +target_link_libraries(${TARGET} + PRIVATE + CompileSettings::CompileSettings + CompileSettingsDebug::CompileSettingsDebug +) + +if(EXCEPTION_CATCHING) + set_source_files_properties(${THUNDER_SOURCE_DIR}/PluginServer.cpp PROPERTIES COMPILE_FLAGS "-fexceptions") +endif() + +target_link_libraries(${TARGET} + 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 + Threads::Threads +) + +# COMProcess is an INTERFACE library carrying only a compile definition. +# It is not in any export set, so we absorb its effect directly. +target_compile_definitions(${TARGET} PRIVATE HOSTING_COMPROCESS=ThunderPlugin) + +# ------------------------------------------------------------------ +# Whole-archive link. Ensures that the MODULE_NAME_DECLARATION +# constructors from the Server objects are not discarded. +# BUILD_INTERFACE only — installed consumers get this via the +# custom Config.cmake.in template instead. +# ------------------------------------------------------------------ +if(APPLE) + target_link_options(${TARGET} INTERFACE + "$>" + ) +else() + target_link_options(${TARGET} INTERFACE + "$ -Wl,--no-whole-archive>" + ) +endif() + +# ------------------------------------------------------------------ +# Optional features – only link when enabled in the main build. +# ------------------------------------------------------------------ +if(WARNING_REPORTING) + target_sources(${TARGET} + PRIVATE + ${THUNDER_SOURCE_DIR}/WarningReportingCategories.cpp + ) +endif() + +if(PROCESSCONTAINERS) + target_link_libraries(${TARGET} + PUBLIC + ${NAMESPACE}ProcessContainers::${NAMESPACE}ProcessContainers + ) + target_compile_definitions(${TARGET} + PUBLIC + PROCESSCONTAINERS_ENABLED=1 + ) +endif() + +if(HIBERNATESUPPORT) + target_link_libraries(${TARGET} + PUBLIC + ${NAMESPACE}Hibernate::${NAMESPACE}Hibernate + ) + target_compile_definitions(${TARGET} + PUBLIC + HIBERNATE_SUPPORT_ENABLED=1 + ) +endif() + +# ------------------------------------------------------------------ +# Install the library and header so external projects can use it +# via find_package(thunder_test_support) +# ------------------------------------------------------------------ +install( + FILES ThunderTestRuntime.h + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/${NAMESPACE}/test_support + COMPONENT ${NAMESPACE}_Development +) + +install( + TARGETS ${TARGET} EXPORT ${TARGET}Targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} COMPONENT ${NAMESPACE}_Development + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/${NAMESPACE} +) + +# ------------------------------------------------------------------ +# thunder_test_main — GTest main() that calls _Exit() after +# RUN_ALL_TESTS() to avoid static destruction order crashes when +# external plugin .so files are loaded. +# +# Plugin tests link this instead of GTest::Main. +# ------------------------------------------------------------------ +set(MAIN_TARGET thunder_test_main) + +add_library(${MAIN_TARGET} STATIC + ThunderTestMain.cpp +) + +target_link_libraries(${MAIN_TARGET} + PUBLIC + GTest::GTest +) + +set_target_properties(${MAIN_TARGET} PROPERTIES + CXX_STANDARD 11 + CXX_STANDARD_REQUIRED YES +) + +install( + TARGETS ${MAIN_TARGET} EXPORT ${MAIN_TARGET}Targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} COMPONENT ${NAMESPACE}_Development +) + +InstallCMakeConfig( + TARGETS ${TARGET} + TEMPLATE ${CMAKE_CURRENT_SOURCE_DIR}/thunder_test_supportConfig.cmake.in +) + +InstallCMakeConfig( + TARGETS ${MAIN_TARGET} +) + +# ------------------------------------------------------------------ +# Smoke test sub-directory +# ------------------------------------------------------------------ +add_subdirectory(tests) diff --git a/Tests/test_support/Module.cpp b/Tests/test_support/Module.cpp new file mode 100644 index 0000000000..e328ce1e4d --- /dev/null +++ b/Tests/test_support/Module.cpp @@ -0,0 +1,3 @@ +#include "Module.h" + +MODULE_NAME_ARCHIVE_DECLARATION diff --git a/Tests/test_support/Module.h b/Tests/test_support/Module.h new file mode 100644 index 0000000000..a54bdb5637 --- /dev/null +++ b/Tests/test_support/Module.h @@ -0,0 +1,10 @@ +#pragma once + +#ifndef MODULE_NAME +#define MODULE_NAME ThunderTestRuntime +#endif + +#include + +#undef EXTERNAL +#define EXTERNAL diff --git a/Tests/test_support/ThunderTestMain.cpp b/Tests/test_support/ThunderTestMain.cpp new file mode 100644 index 0000000000..b9ba67adea --- /dev/null +++ b/Tests/test_support/ThunderTestMain.cpp @@ -0,0 +1,28 @@ +// ========================================================================== +// ThunderTestMain — GTest main() for plugin integration tests. +// +// When a test binary loads external plugin shared libraries (.so) via the +// ThunderTestRuntime, those libraries may register static objects whose +// destruction order at process exit conflicts with Thunder's own +// singletons (WorkerPool, ResourceMonitor, etc.). +// +// This main() calls _Exit() after RUN_ALL_TESTS() to skip static +// destruction entirely, matching the real Thunder daemon which calls +// exit(0) after its CloseDown() sequence. +// +// Link against thunder_test_main instead of GTest::Main: +// target_link_libraries(my_test PRIVATE +// thunder_test_support::thunder_test_support +// thunder_test_main::thunder_test_main +// GTest::GTest) +// ========================================================================== + +#include +#include + +int main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + int result = RUN_ALL_TESTS(); + _Exit(result); +} diff --git a/Tests/test_support/ThunderTestRuntime.cpp b/Tests/test_support/ThunderTestRuntime.cpp new file mode 100644 index 0000000000..56dbf6803b --- /dev/null +++ b/Tests/test_support/ThunderTestRuntime.cpp @@ -0,0 +1,460 @@ +#include "Module.h" +#include "ThunderTestRuntime.h" + +#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 { + + // ================================================================== + // JSONRPCLink implementation + // ================================================================== + + ThunderTestRuntime::JSONRPCLink::JSONRPCLink(ThunderTestRuntime& runtime, const string& callsign) + : _runtime(runtime) + , _callsign(callsign) + , _dispatcher(nullptr) + { + Core::ProxyType shell = _runtime.GetShell(_callsign); + if (shell.IsValid()) { + _dispatcher = shell->QueryInterface(); + } + } + + ThunderTestRuntime::JSONRPCLink::~JSONRPCLink() + { + // Unsubscribe from all active events + if (_dispatcher != nullptr) { + std::lock_guard guard(_lock); + for (const auto& entry : _handlers) { + _dispatcher->Unsubscribe(this, entry.first, _callsign, string()); + } + _handlers.clear(); + _dispatcher->Release(); + } + } + + uint32_t ThunderTestRuntime::JSONRPCLink::Invoke(const string& method, + const string& params, + string& response) + { + uint32_t result = Core::ERROR_UNAVAILABLE; + + if (_dispatcher != nullptr) { + + string fullMethod = _callsign + '.' + method; + + if (_runtime.MethodExists(_dispatcher, _callsign, method) == false) { + result = Core::ERROR_UNKNOWN_KEY; + } else { + result = _dispatcher->Invoke(0, 0, string(), fullMethod, params, response); + } + } + + return result; + } + + uint32_t ThunderTestRuntime::JSONRPCLink::Subscribe(const string& event, EventHandler handler) + { + uint32_t result = Core::ERROR_UNAVAILABLE; + + if (_dispatcher != nullptr) { + + result = _dispatcher->Subscribe(this, event, _callsign, string()); + + if (result == Core::ERROR_NONE) { + std::lock_guard guard(_lock); + _handlers[event] = std::move(handler); + } + } + + return result; + } + + uint32_t ThunderTestRuntime::JSONRPCLink::Unsubscribe(const string& event) + { + uint32_t result = Core::ERROR_UNAVAILABLE; + + if (_dispatcher != nullptr) { + + result = _dispatcher->Unsubscribe(this, event, _callsign, string()); + + std::lock_guard guard(_lock); + _handlers.erase(event); + } + + return result; + } + + Core::hresult ThunderTestRuntime::JSONRPCLink::Event(const string& event, + const string& /* designator */, + const string& /* index */, + const string& parameters) + { + std::lock_guard guard(_lock); + + auto it = _handlers.find(event); + if (it != _handlers.end()) { + it->second(parameters); + } + + return Core::ERROR_NONE; + } + + // ================================================================== + // ThunderTestRuntime implementation + // ================================================================== + + 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() == false) { + Core::Directory(_tempDir.c_str()).Destroy(); + + // Core::Directory::Destroy() does not remove the directory if the path + // ends with a trailing separator. Strip it before the final call. + string path = _tempDir; + while ((path.empty() == false) && (path.back() == '/' || path.back() == '\\')) { + path.pop_back(); + } + if (path.empty() == false) { + 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(); + + _initialized = true; + + return Core::ERROR_NONE; + } + + ThunderTestRuntime::JSONRPCLink* ThunderTestRuntime::CreateJSONRPCLink(const string& callsign) + { + return new class JSONRPCLink(*this, callsign); + } + + // Invoke a JSON-RPC method synchronously via the in-process dispatcher. + // Bypasses HTTP/WebSocket — calls IDispatcher::Invoke() directly. + // Callsign and method are parsed using Core::JSONRPC::Message helpers. + 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; + } + + if (_initialized == true) { + Messaging::MessageUnit::Instance().Close(); + _initialized = false; + } + + if (_configFilePath.empty() == false) { + 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 0000000000..ac7433cd98 --- /dev/null +++ b/Tests/test_support/ThunderTestRuntime.h @@ -0,0 +1,183 @@ +#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, event handling, +// 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; +// auto link = _runtime.JSONRPCLink("MyPlugin"); +// link.Invoke("someMethod", R"({"param":1})", resp); +// } +// +// TEST_F(MyTest, Events) { +// auto link = _runtime.JSONRPCLink("MyPlugin"); +// link.Subscribe("onSomethingChanged", +// [](const string& params) { /* handle event */ }); +// // ... trigger event ... +// link.Unsubscribe("onSomethingChanged"); +// } +// +// TEST_F(MyTest, ComRpc) { +// auto* iface = _runtime.GetInterface("MyPlugin"); +// } +// ========================================================================== + +#include +#include +#include +#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; + + // ------------------------------------------------------------------ + // JSONRPCLink — callsign-bound helper for JSON-RPC calls and events + // ------------------------------------------------------------------ + class JSONRPCLink : public PluginHost::IDispatcher::ICallback { + public: + using EventHandler = std::function; + + JSONRPCLink(ThunderTestRuntime& runtime, const string& callsign); + ~JSONRPCLink() override; + + JSONRPCLink(const JSONRPCLink&) = delete; + JSONRPCLink& operator=(const JSONRPCLink&) = delete; + + // JSON-RPC method invocation (callsign is implicit). + // Method is the bare method name (e.g. "getLogLevel"), not "Callsign.getLogLevel". + uint32_t Invoke(const string& method, const string& params, string& response); + + // Subscribe to a JSON-RPC event with a callback. + uint32_t Subscribe(const string& event, EventHandler handler); + + // Unsubscribe from a previously subscribed event. + uint32_t Unsubscribe(const string& event); + + const string& Callsign() const { return _callsign; } + + // IDispatcher::ICallback + uint32_t AddRef() const override { return Core::ERROR_NONE; } + uint32_t Release() const override { return Core::ERROR_NONE; } + + private: + // IDispatcher::ICallback + Core::hresult Event(const string& event, const string& designator, + const string& index, const string& parameters) override; + + BEGIN_INTERFACE_MAP(JSONRPCLink) + INTERFACE_ENTRY(PluginHost::IDispatcher::ICallback) + END_INTERFACE_MAP + + ThunderTestRuntime& _runtime; + string _callsign; + PluginHost::IDispatcher* _dispatcher; + + mutable std::mutex _lock; + std::unordered_map _handlers; + }; + + 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 = ""); + + // Create a callsign-bound JSON-RPC link for invoke and event operations. + // Caller owns the returned object. + class JSONRPCLink* CreateJSONRPCLink(const string& callsign); + + // Invoke a JSON-RPC method on a loaded plugin (full designator form). + // 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 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; + bool _initialized = false; + 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 0000000000..5e6463e706 --- /dev/null +++ b/Tests/test_support/tests/CMakeLists.txt @@ -0,0 +1,23 @@ +set(TARGET thunder_test_runtime_smoke) + +find_package(GTest REQUIRED) + +add_executable(${TARGET} + SmokeTest.cpp + Module.cpp +) + +target_compile_definitions(${TARGET} + PRIVATE + MODULE_NAME=SmokeTest + BUILD_REFERENCE=${BUILD_REFERENCE} +) + +target_link_libraries(${TARGET} + PRIVATE + thunder_test_support + GTest::GTest + GTest::Main +) + +add_test(NAME ${TARGET} COMMAND ${TARGET}) diff --git a/Tests/test_support/tests/Module.cpp b/Tests/test_support/tests/Module.cpp new file mode 100644 index 0000000000..2d85ed902b --- /dev/null +++ b/Tests/test_support/tests/Module.cpp @@ -0,0 +1,3 @@ +#include "Module.h" + +MODULE_NAME_DECLARATION(BUILD_REFERENCE) diff --git a/Tests/test_support/tests/Module.h b/Tests/test_support/tests/Module.h new file mode 100644 index 0000000000..c8b64442c0 --- /dev/null +++ b/Tests/test_support/tests/Module.h @@ -0,0 +1,10 @@ +#pragma once + +#ifndef MODULE_NAME +#define MODULE_NAME SmokeTest +#endif + +#include + +#undef EXTERNAL +#define EXTERNAL diff --git a/Tests/test_support/tests/SmokeTest.cpp b/Tests/test_support/tests/SmokeTest.cpp new file mode 100644 index 0000000000..ef75e81797 --- /dev/null +++ b/Tests/test_support/tests/SmokeTest.cpp @@ -0,0 +1,123 @@ +// ========================================================================== +// SmokeTest — validates the ThunderTestRuntime boots and tears down cleanly. +// +// These tests exercise: +// 1. Invoke() with the full "Callsign.method" form +// 2. JSONRPCLink (callsign-bound) invocation +// 3. GetShell() COM-RPC path +// ========================================================================== + +#include "Module.h" +#include "ThunderTestRuntime.h" +#include +#include + +namespace Thunder { +namespace TestCore { +namespace Tests { + + class SmokeTest : public ::testing::Test { + protected: + static ThunderTestRuntime _runtime; + + static void SetUpTestSuite() + { + 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(); + } + }; + + ThunderTestRuntime SmokeTest::_runtime; + + // ------------------------------------------------------------------ + // Full-designator Invoke: "Controller.status" + // ------------------------------------------------------------------ + TEST_F(SmokeTest, ControllerStatusViaFullDesignator) + { + string response; + uint32_t result = _runtime.Invoke("Controller.status", "{}", response); + EXPECT_EQ(result, Core::ERROR_NONE) << "Controller.status returned: " << result; + EXPECT_FALSE(response.empty()); + } + + // ------------------------------------------------------------------ + // Full-designator Invoke: "Controller.subsystems" + // ------------------------------------------------------------------ + TEST_F(SmokeTest, ControllerSubsystemsViaFullDesignator) + { + string response; + uint32_t result = _runtime.Invoke("Controller.subsystems", "{}", response); + EXPECT_EQ(result, Core::ERROR_NONE) << "Controller.subsystems returned: " << result; + EXPECT_FALSE(response.empty()); + } + + // ------------------------------------------------------------------ + // JSONRPCLink (callsign-bound): invoke without repeating callsign + // ------------------------------------------------------------------ + TEST_F(SmokeTest, ControllerStatusViaJSONRPCLink) + { + auto* controller = _runtime.CreateJSONRPCLink("Controller"); + ASSERT_NE(controller, nullptr); + + string response; + uint32_t result = controller->Invoke("status", "{}", response); + EXPECT_EQ(result, Core::ERROR_NONE) << "status via link returned: " << result; + EXPECT_FALSE(response.empty()); + + delete controller; + } + + // ------------------------------------------------------------------ + // GetShell: obtain the IShell for the Controller + // ------------------------------------------------------------------ + TEST_F(SmokeTest, GetControllerShell) + { + auto shell = _runtime.GetShell("Controller"); + EXPECT_TRUE(shell.IsValid()) << "Controller IShell must be available"; + } + + // ------------------------------------------------------------------ + // Unknown method returns ERROR_UNKNOWN_KEY + // ------------------------------------------------------------------ + TEST_F(SmokeTest, UnknownMethodReturnsError) + { + string response; + uint32_t result = _runtime.Invoke("Controller.thisMethodDoesNotExist", "{}", response); + EXPECT_EQ(result, Core::ERROR_UNKNOWN_KEY); + } + + // ------------------------------------------------------------------ + // Unknown method via JSONRPCLink + // ------------------------------------------------------------------ + TEST_F(SmokeTest, UnknownMethodViaJSONRPCLinkReturnsError) + { + auto* controller = _runtime.CreateJSONRPCLink("Controller"); + ASSERT_NE(controller, nullptr); + + string response; + uint32_t result = controller->Invoke("thisMethodDoesNotExist", "{}", response); + EXPECT_EQ(result, Core::ERROR_UNKNOWN_KEY); + + delete controller; + } + + // ------------------------------------------------------------------ + // Missing callsign in full-designator form returns error + // ------------------------------------------------------------------ + TEST_F(SmokeTest, MissingCallsignReturnsError) + { + string response; + uint32_t result = _runtime.Invoke("noCallsignDot", "{}", response); + EXPECT_NE(result, Core::ERROR_NONE); + } + +} // namespace Tests +} // namespace TestCore +} // namespace Thunder diff --git a/Tests/test_support/thunder_test_supportConfig.cmake.in b/Tests/test_support/thunder_test_supportConfig.cmake.in new file mode 100644 index 0000000000..ae044d95b3 --- /dev/null +++ b/Tests/test_support/thunder_test_supportConfig.cmake.in @@ -0,0 +1,20 @@ +set(dependencies @dependencies@) + +foreach(dependency ${dependencies}) + find_package(${dependency} REQUIRED) +endforeach() + +get_filename_component(_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH) +include(${_DIR}/@_name@Targets.cmake) + +# Whole-archive linking ensures MODULE_NAME_DECLARATION static constructors +# from the embedded PluginHost::Server objects are not discarded by the linker. +if(TARGET @_name@::@_name@) + if(APPLE) + set_property(TARGET @_name@::@_name@ APPEND PROPERTY + INTERFACE_LINK_OPTIONS "SHELL:-Wl,-force_load,$") + else() + set_property(TARGET @_name@::@_name@ APPEND PROPERTY + INTERFACE_LINK_OPTIONS "SHELL:-Wl,--whole-archive $ -Wl,--no-whole-archive") + endif() +endif()