diff --git a/ci/run-unit-tests.js b/ci/run-unit-tests.js index aabf4d4ff..586feb5d5 100644 --- a/ci/run-unit-tests.js +++ b/ci/run-unit-tests.js @@ -10,12 +10,14 @@ const buildDirectory = process.env.BUILD_DIRECTORY || "build"; const buildConfig = process.env.BUILD_CONFIG || process.env.BuildConfig || "RelWithDebInfo"; -const target = "obs_studio_client_unit_tests"; -// Match the TEST_PREFIX passed to catch_discover_tests so ctest only runs this unit-test suite. -const testPattern = `^${target}::`; -function hasDiscoveredTests() { - const testsDirectory = path.join(buildDirectory, "obs-studio-client"); +const testSuites = [ + { target: "obs_studio_client_unit_tests", sourceDir: "obs-studio-client" }, + { target: "obs_studio_server_unit_tests", sourceDir: "obs-studio-server" }, +]; + +function hasDiscoveredTests(target, sourceDir) { + const testsDirectory = path.join(buildDirectory, sourceDir); try { return fs @@ -39,16 +41,18 @@ function run(command, args) { } } -const skipBuild = - process.env.OSN_SKIP_UNIT_TEST_BUILD === "1" || - // Test jobs run from uploaded build artifacts. Building again can force CMake - // to reconfigure FetchContent checkouts whose hidden .git directories were not uploaded. - (process.env.GITHUB_ACTIONS === "true" && hasDiscoveredTests()); +for (const { target, sourceDir } of testSuites) { + const testPattern = `^${target}::`; -if (skipBuild) { - console.log("Skipping unit-test build; discovered CTest tests are already present."); -} else { - run("cmake", ["--build", buildDirectory, "--config", buildConfig, "--target", target]); -} + const skipBuild = + process.env.OSN_SKIP_UNIT_TEST_BUILD === "1" || + (process.env.GITHUB_ACTIONS === "true" && hasDiscoveredTests(target, sourceDir)); -run("ctest", ["--test-dir", buildDirectory, "-C", buildConfig, "--output-on-failure", "-R", testPattern]); + if (skipBuild) { + console.log(`Skipping build for ${target}; discovered CTest tests are already present.`); + } else { + run("cmake", ["--build", buildDirectory, "--config", buildConfig, "--target", target]); + } + + run("ctest", ["--test-dir", buildDirectory, "-C", buildConfig, "--output-on-failure", "-R", testPattern]); +} diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index d83aab614..3263b907b 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -424,9 +424,38 @@ elseif(WIN32) ) endif () +set(OSN_SERVER_CORE_SOURCES ${osn-server_SOURCES}) +list(REMOVE_ITEM OSN_SERVER_CORE_SOURCES "${PROJECT_SOURCE_DIR}/source/main.cpp") + +add_library( + obs-studio-server-lib STATIC + ${OSN_SERVER_CORE_SOURCES} +) + +IF(WIN32) + target_compile_definitions( + obs-studio-server-lib + PUBLIC + WIN32_LEAN_AND_MEAN + NOMINMAX + UNICODE + _UNICODE + ) +ENDIF() + +target_include_directories(obs-studio-server-lib PUBLIC ${PROJECT_INCLUDE_PATHS}) + +if(WIN32) + target_link_libraries(obs-studio-server-lib PUBLIC ${PROJECT_LIBRARIES} optimized crashpad strmiids StackWalker) +else() + target_link_libraries(obs-studio-server-lib PUBLIC ${PROJECT_LIBRARIES} crashpad ${COREFOUNDATION} ${COCOA} ${IOSURF} ${GLKIT} ${AVFOUNDATION} ${IOKit} ${SECURITY_LIBRARY} ${BSM_LIBRARY}) +endif() + +target_link_libraries(obs-studio-server-lib PUBLIC libcurl) + add_executable( ${PROJECT_NAME} - ${osn-server_SOURCES} + "${PROJECT_SOURCE_DIR}/source/main.cpp" ) if(WIN32) @@ -436,16 +465,7 @@ if(WIN32) target_sources(${PROJECT_NAME} PUBLIC "${PROJECT_BINARY_DIR}/version.rc") endif() -#target_link_libraries(${PROJECT_NAME} CURL::libcurl) -target_link_libraries(${PROJECT_NAME} libcurl) - -if(WIN32) - target_include_directories(${PROJECT_NAME} PUBLIC ${PROJECT_INCLUDE_PATHS}) - target_link_libraries(${PROJECT_NAME} ${PROJECT_LIBRARIES} optimized crashpad strmiids) -else() - target_include_directories(${PROJECT_NAME} PUBLIC ${PROJECT_INCLUDE_PATHS} ${COREFOUNDATION} ${COCOA} ${IOSURF} ${GLKIT} ${AVFOUNDATION} ${IOKit} ${SECURITY_LIBRARY} ${BSM_LIBRARY}) - target_link_libraries(${PROJECT_NAME} ${PROJECT_LIBRARIES} crashpad ${COREFOUNDATION} ${COCOA} ${IOSURF} ${GLKIT} ${AVFOUNDATION} ${IOKit} ${SECURITY_LIBRARY} ${BSM_LIBRARY}) -endif() +target_link_libraries(${PROJECT_NAME} obs-studio-server-lib) if(MSVC) add_definitions(-D_CRT_SECURE_NO_WARNINGS -D_SILENCE_ALL_CXX17_DEPRECATION_WARNINGS) @@ -483,17 +503,6 @@ else() ) ENDIF() -IF(WIN32) - target_compile_definitions( - ${PROJECT_NAME} - PRIVATE - WIN32_LEAN_AND_MEAN - NOMINMAX - UNICODE - _UNICODE - ) -ENDIF() - IF( NOT CLANG_ANALYZE_CONFIG) cppcheck_add_project(${PROJECT_NAME}) ENDIF() @@ -598,3 +607,52 @@ if (APPLE) DESTINATION "../../Contents/Frameworks" USE_SOURCE_PERMISSIONS ) endif() + +if(BUILD_TESTING) + include(Catch) + + add_executable( + obs_studio_server_unit_tests + "tests/test-osn-source.cpp" + "tests/obs-setup.cpp" + "tests/obs-setup.hpp" + ) + + target_compile_definitions( + obs_studio_server_unit_tests + PRIVATE + OSN_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + OSN_TEST_WD="${CMAKE_INSTALL_PREFIX}" + ) + + target_link_libraries( + obs_studio_server_unit_tests + PRIVATE + Catch2::Catch2WithMain + obs-studio-server-lib + ) + + if(APPLE) + add_custom_command( + TARGET obs_studio_server_unit_tests + POST_BUILD + COMMAND /usr/bin/codesign --force --sign - "$" + COMMENT "Ad-hoc signing obs_studio_server_unit_tests before Catch2 test discovery" + VERBATIM + ) + endif() + + catch_discover_tests( + obs_studio_server_unit_tests + TEST_PREFIX "obs_studio_server_unit_tests::" + DL_PATHS + "$" + "$" + "$" +if(APPLE) + "${libobs_SOURCE_DIR}/OBS.app/Contents/Frameworks" +elseif(WIN32) + "${libobs_SOURCE_DIR}/bin/64bit" +endif() + ) +endif() \ No newline at end of file diff --git a/obs-studio-server/source/nodeobs_api.cpp b/obs-studio-server/source/nodeobs_api.cpp index 570081273..32a1a0879 100644 --- a/obs-studio-server/source/nodeobs_api.cpp +++ b/obs-studio-server/source/nodeobs_api.cpp @@ -923,19 +923,21 @@ void OBS_API::OBS_API_initAPI(void *data, const int64_t id, const std::vectorset_pre_callback( - [](std::string cname, std::string fname, const std::vector &args, void *data) { - util::CrashManager &crashManager = *static_cast(data); - crashManager.ProcessPreServerCall(cname, fname, args); - }, - &crashManager); - g_server->set_post_callback( - [](std::string cname, std::string fname, const std::vector &args, void *data) { - util::CrashManager &crashManager = *static_cast(data); - crashManager.ProcessPostServerCall(cname, fname, args); - }, - &crashManager); + if (g_server) { + // Register the pre and post server callbacks to log the data into the crashmanager + g_server->set_pre_callback( + [](std::string cname, std::string fname, const std::vector &args, void *data) { + util::CrashManager &crashManager = *static_cast(data); + crashManager.ProcessPreServerCall(cname, fname, args); + }, + &crashManager); + g_server->set_post_callback( + [](std::string cname, std::string fname, const std::vector &args, void *data) { + util::CrashManager &crashManager = *static_cast(data); + crashManager.ProcessPostServerCall(cname, fname, args); + }, + &crashManager); + } #endif diff --git a/obs-studio-server/tests/obs-setup.cpp b/obs-studio-server/tests/obs-setup.cpp new file mode 100644 index 000000000..88e2b192b --- /dev/null +++ b/obs-studio-server/tests/obs-setup.cpp @@ -0,0 +1,58 @@ +#include +#include +#include "nodeobs_api.h" +#include "osn-error.hpp" +#include +#include "shared.hpp" +#include +#include "obs-setup.hpp" +#include + +namespace osn::tests { + +void setWorkingFolder(const std::string &wd) +{ + std::vector args = {ipc::value(wd)}; + std::vector response; + OBS_API::SetWorkingDirectory(nullptr, 0, args, response); + REQUIRE(response.size() >= 2); + ErrorCode error = (ErrorCode)response[0].value_union.ui64; + CHECK(error == ErrorCode::Ok); +} + +void setupApi() +{ +#if defined(__APPLE__) + g_util_osx = new UtilInt(); + g_util_osx->init(); + // Workaround normal app startup where "browser_source" plugin is initialized + CHECK(!g_util_osx->hasInitApi()); + g_util_osx->nextState(); + CHECK(g_util_osx->hasInitApi()); +#endif + const std::string appPath = std::string(OSN_SOURCE_DIR) + "/tests/osn-tests/osnData/slobs-client"; + std::vector args = {ipc::value(appPath), ipc::value("en-US"), ipc::value("0.00.00-preview.0"), ipc::value("")}; + std::vector response; + OBS_API::OBS_API_initAPI(nullptr, 0, args, response); + REQUIRE(response.size() >= 2); + ErrorCode error = (ErrorCode)response[0].value_union.ui64; + CHECK(error == ErrorCode::Ok); +} + +ObsSetup::ObsSetup() +{ + setWorkingFolder(OSN_TEST_WD); + setupApi(); +} + +ObsSetup::~ObsSetup() +{ + std::vector args = {}; + std::vector response; + OBS_API::OBS_API_destroyOBS_API(nullptr, 0, args, response); + REQUIRE(response.size() >= 1); + ErrorCode error = (ErrorCode)response[0].value_union.ui64; + CHECK(error == ErrorCode::Ok); +} + +} // namespace osn::tests diff --git a/obs-studio-server/tests/obs-setup.hpp b/obs-studio-server/tests/obs-setup.hpp new file mode 100644 index 000000000..75c124ffe --- /dev/null +++ b/obs-studio-server/tests/obs-setup.hpp @@ -0,0 +1,10 @@ +#pragma once + +namespace osn::tests { +// This helper object uses RAII pattern to initialize & destroy OBS API +class ObsSetup { +public: + ObsSetup(); + ~ObsSetup(); +}; +} // namespace osn::tests diff --git a/obs-studio-server/tests/test-osn-source.cpp b/obs-studio-server/tests/test-osn-source.cpp new file mode 100644 index 000000000..b59fd0bab --- /dev/null +++ b/obs-studio-server/tests/test-osn-source.cpp @@ -0,0 +1,87 @@ +#include +#include +#include "nodeobs_api.h" +#include "osn-error.hpp" +#include "osn-input.hpp" +#include "osn-source.hpp" +#include +#include "shared.hpp" +#include +#include "obs-setup.hpp" +#include +#include + +TEST_CASE("Run osn::source tests") +{ + osn::tests::ObsSetup setupOBS; + + SECTION("Get properties of browser source while releasing concurrently does not crash") + { + auto sourceCount = osn::Source::Manager::GetInstance().size(); + const int iterations = 20; + std::vector workers; + std::vector releaseOk(iterations, 0); + std::vector getPropertiesCode(iterations, ErrorCode::Error); + + for (int i = 0; i < iterations; i++) { + const std::string sourceName = "test-input-" + std::to_string(i); + std::vector args = {ipc::value("browser_source"), ipc::value(sourceName)}; + std::vector response; + + osn::Input::Create(nullptr, 0, args, response); + REQUIRE(response.size() >= 2); + ErrorCode error = (ErrorCode)response[0].value_union.ui64; + REQUIRE(error == ErrorCode::Ok); + + uint64_t sourceId = response[1].value_union.ui64; + + workers.push_back(std::thread([sourceId, i, &getPropertiesCode]() { + std::vector propArgs = {ipc::value(sourceId)}; + std::vector propResponse; + osn::Source::GetProperties(nullptr, 0, propArgs, propResponse); + if (propResponse.size() >= 1) { + getPropertiesCode[i] = (ErrorCode)propResponse[0].value_union.ui64; + } + })); + + workers.push_back(std::thread([sourceId, i, &releaseOk]() { + +#if defined(TRIGGER_CRASH) + // TODO: Enable this code block once staging (commit cc4a0431) is merged. + // Release the refcount to trigger actual private data destruction + obs_source_t *src = osn::Source::Manager::GetInstance().find(sourceId); // may be null already + if (src) { + obs_source_release(src); + releaseOk[i] = true; + } +#else + // TODO: delete this block after staging (commit cc4a0431) is merged. + std::vector propArgs = {ipc::value(sourceId)}; + std::vector propResponse; + osn::Source::Release(nullptr, 0, propArgs, propResponse); + // Capture result for checking on the main thread after join. + if (propResponse.size() >= 1) { + releaseOk[i] = ((ErrorCode)propResponse[0].value_union.ui64 == ErrorCode::Ok); + } +#endif + })); + } + + for (std::thread &worker : workers) { + if (worker.joinable()) + worker.join(); + } + + // Check release results on the main thread where Catch2 is safe to use. + for (int i = 0; i < iterations; i++) { + CHECK(releaseOk[i]); + // ErrorCode::InvalidReference is possible if the source was deleted before we could acquire the source + bool expectedErrorCode = getPropertiesCode[i] == ErrorCode::Ok || getPropertiesCode[i] == ErrorCode::InvalidReference; + CHECK(expectedErrorCode); + } +#if defined(TRIGGER_CRASH) + // TODO: Enable this code block once staging (commit cc4a0431) is merged. + CHECK(sourceCount == osn::Source::Manager::GetInstance().size()); // Check to see if all objects released. +#endif + } +}