diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92ce267 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +--- +name: CI + +on: + pull_request: + branches: + - master + types: + - opened + - synchronize + - reopened + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_win: + name: Windows + runs-on: windows-2019 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Prepare tests + id: prepare-tests + run: | + # function to download and extract a zip file + function DownloadAndExtract { + param ( + [string]$Uri, + [string]$OutFile + ) + + $maxRetries = 5 + $retryCount = 0 + $success = $false + + while (-not $success -and $retryCount -lt $maxRetries) { + $retryCount++ + Write-Host "Downloading $Uri to $OutFile, attempt $retryCount of $maxRetries" + try { + Invoke-WebRequest -Uri $Uri -OutFile $OutFile + $success = $true + } catch { + Write-Host "Attempt $retryCount of $maxRetries failed with error: $($_.Exception.Message). Retrying..." + Start-Sleep -Seconds 5 + } + } + + if (-not $success) { + Write-Host "Failed to download the file after $maxRetries attempts." + exit 1 + } + + # use .NET to get the base name of the file + $baseName = (Get-Item $OutFile).BaseName + + # Extract the zip file + Expand-Archive -Path $OutFile -DestinationPath $baseName + } + + # virtual display driver + DownloadAndExtract ` + -Uri "https://www.amyuni.com/downloads/usbmmidd_v2.zip" ` + -OutFile "usbmmidd_v2.zip" + + # install + Set-Location -Path usbmmidd_v2/usbmmidd_v2 + ./deviceinstaller64 install usbmmidd.inf usbmmidd + + # create up to 4 virtual displays + for ($i = 1; $i -le 4; $i++) { + ./deviceinstaller64 enableidd 1 + } + + - name: Setup python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Python Path + id: python-path + shell: msys2 {0} + run: | + # replace backslashes with double backslashes + python_path=$(echo "${{ steps.setup-python.outputs.python-path }}" | sed 's/\\/\\\\/g') + + # step output + echo "python-path=${python_path}" + echo "python-path=${python_path}" >> $GITHUB_OUTPUT + + - name: Setup Dependencies Windows + uses: msys2/setup-msys2@v2 + with: + update: true + install: >- + base-devel + make + mingw-w64-x86_64-binutils + mingw-w64-x86_64-cmake + mingw-w64-x86_64-toolchain + + - name: Build Windows + shell: msys2 {0} + run: | + mkdir build + cd build + cmake \ + -G "MinGW Makefiles" \ + .. + mingw32-make -j$(nproc) + + - name: Run tests + id: test + shell: msys2 {0} + working-directory: build/tests + run: | + ./test_libdisplaydevice.exe --gtest_color=yes + + - name: Generate gcov report + # any except canceled or skipped + if: always() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + id: test_report + shell: msys2 {0} + working-directory: build + run: | + ${{ steps.python-path.outputs.python-path }} -m pip install gcovr + ${{ steps.python-path.outputs.python-path }} -m gcovr -r .. \ + --exclude ../tests/ \ + --exclude ../third-party/ \ + --xml-pretty \ + -o coverage.xml + + - name: Upload coverage + # any except canceled or skipped + if: always() && (steps.test_report.outcome == 'success') + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + files: ./build/coverage.xml + flags: ${{ runner.os }} + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cab683a --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# JetBrains IDE +.idea/ + +# build directories +build/ +cmake-*/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..79ca3b6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "third-party/googletest"] + path = third-party/googletest + url = https://github.com/google/googletest.git + branch = v1.14.x +[submodule "third-party/json"] + path = third-party/json + url = https://github.com/nlohmann/json.git + branch = master diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d74e5be --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.13) # todo: what is the minimum version required? + +project(libdisplaydevice + DESCRIPTION "Library to modify display devices." + HOMEPAGE_URL "https://app.lizardbyte.dev") + +set(PROJECT_LICENSE "GPL-3.0") + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + message(STATUS "Setting build type to 'Release' as none was specified.") + set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE) +endif() + +set(CMAKE_CXX_STANDARD 17) + +# set the module path, used for includes +set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") + +# options +option(BUILD_TESTS "Build tests" ON) + +# if windows +if(WIN32) + include(${CMAKE_MODULE_PATH}/windows.cmake) +elseif(APPLE) + message(FATAL_ERROR "MacOS is not supported yet.") + # include(${CMAKE_MODULE_PATH}/macos.cmake) +else() + message(FATAL_ERROR "Linux is not supported yet.") + # include(${CMAKE_MODULE_PATH}/linux.cmake) +endif() + +# glob src +file(GLOB_RECURSE SRC_FILES src/*.cpp) + +# tests +if(BUILD_TESTS) + add_subdirectory(tests) +endif() + +# lib +add_library(${PROJECT_NAME} ${SRC_FILES}) +target_include_directories(${PROJECT_NAME} PUBLIC include) diff --git a/cmake/windows.cmake b/cmake/windows.cmake new file mode 100644 index 0000000..e69de29 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8fd16a8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,10 @@ +/** + * @file main.cpp + */ + +#include + +int main() { + std::cout << "Hello, World!" << std::endl; + return 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..60ad7c1 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,52 @@ +cmake_minimum_required(VERSION 3.13) +# https://github.com/google/oss-policies-info/blob/main/foundational-cxx-support-matrix.md#foundational-c-support + +project(test_libdisplaydevice) + +include_directories("${CMAKE_SOURCE_DIR}") + +enable_testing() + +# Add GoogleTest directory to the project +set(GTEST_SOURCE_DIR "${CMAKE_SOURCE_DIR}/third-party/googletest") +set(INSTALL_GTEST OFF) +set(INSTALL_GMOCK OFF) +add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest") +include_directories("${GTEST_SOURCE_DIR}/googletest/include" "${GTEST_SOURCE_DIR}") + +# coverage +# https://gcovr.com/en/stable/guide/compiling.html#compiler-options +set(CMAKE_CXX_FLAGS "-fprofile-arcs -ftest-coverage -O1") +set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -O1") + +# if windows +if (WIN32) + # For Windows: Prevent overriding the parent project's compiler/linker settings + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 +endif () + +file(GLOB_RECURSE TEST_SOURCES + ${CMAKE_SOURCE_DIR}/tests/conftest.cpp + ${CMAKE_SOURCE_DIR}/tests/utils.cpp + ${CMAKE_SOURCE_DIR}/tests/test_*.cpp) + +set(DD_SOURCES + ${DD_FILES}) + +# remove main.cpp from the list of sources +list(REMOVE_ITEM DD_SOURCES ${CMAKE_SOURCE_DIR}/src/main.cpp) + +add_executable(${PROJECT_NAME} + ${TEST_SOURCES} + ${DISPLAY_DEVICE_SOURCES}) +set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) +target_link_libraries(${PROJECT_NAME} + ${DD_EXTERNAL_LIBRARIES} + gtest + gtest_main # if we use this we don't need our own main function + ${PLATFORM_LIBRARIES}) +target_compile_definitions(${PROJECT_NAME} PUBLIC ${DD_DEFINITIONS} ${TEST_DEFINITIONS}) +target_compile_options(${PROJECT_NAME} PRIVATE $<$:${DD_COMPILE_OPTIONS}> -std=c++17) +target_link_options(${PROJECT_NAME} PRIVATE) + +add_test(NAME ${PROJECT_NAME} COMMAND libdisplaydevice_test) diff --git a/tests/conftest.cpp b/tests/conftest.cpp new file mode 100644 index 0000000..c3ba939 --- /dev/null +++ b/tests/conftest.cpp @@ -0,0 +1,127 @@ +#include +#include + +#include + +// Undefine the original TEST macro +#undef TEST + +// Redefine TEST to use our BaseTest class, to automatically use our BaseTest fixture +#define TEST(test_case_name, test_name) \ + GTEST_TEST_(test_case_name, test_name, ::BaseTest, \ + ::testing::internal::GetTypeId<::BaseTest>()) + +/** + * @brief Base class for tests. + * + * This class provides a base test fixture for all tests. + * + * ``cout``, ``stderr``, and ``stdout`` are redirected to a buffer, and the buffer is printed if the test fails. + * + * @todo Retain the color of the original output. + */ +class BaseTest: public ::testing::Test { +protected: + // https://stackoverflow.com/a/58369622/11214013 + + // we can possibly use some internal googletest functions to capture stdout and stderr, but I have not tested this + // https://stackoverflow.com/a/33186201/11214013 + + BaseTest(): + sbuf { nullptr }, pipe_stdout { nullptr }, pipe_stderr { nullptr } { + // intentionally empty + } + + ~BaseTest() override = default; + + void + SetUp() override { + // todo: only run this one time, instead of every time a test is run + // see: https://stackoverflow.com/questions/2435277/googletest-accessing-the-environment-from-a-test + // get command line args from the test executable + testArgs = ::testing::internal::GetArgvs(); + + // then get the directory of the test executable + // std::string path = ::testing::internal::GetArgvs()[0]; + testBinary = testArgs[0]; + + // get the directory of the test executable + testBinaryDir = std::filesystem::path(testBinary).parent_path(); + + // If testBinaryDir is empty or `.` then set it to the current directory + // maybe some better options here: https://stackoverflow.com/questions/875249/how-to-get-current-directory + if (testBinaryDir.empty() || testBinaryDir.string() == ".") { + testBinaryDir = std::filesystem::current_path(); + } + + sbuf = std::cout.rdbuf(); // save cout buffer (std::cout) + std::cout.rdbuf(cout_buffer.rdbuf()); // redirect cout to buffer (std::cout) + } + + void + TearDown() override { + std::cout.rdbuf(sbuf); // restore cout buffer + + // get test info + const ::testing::TestInfo *const test_info = ::testing::UnitTest::GetInstance()->current_test_info(); + + if (test_info->result()->Failed()) { + std::cout << std::endl + << "Test failed: " << test_info->name() << std::endl + << std::endl + << "Captured boost log:" << std::endl + << boost_log_buffer.str() << std::endl + << "Captured cout:" << std::endl + << cout_buffer.str() << std::endl + << "Captured stdout:" << std::endl + << stdout_buffer.str() << std::endl + << "Captured stderr:" << std::endl + << stderr_buffer.str() << std::endl; + } + + sbuf = nullptr; // clear sbuf + if (pipe_stdout) { + pclose(pipe_stdout); + pipe_stdout = nullptr; + } + if (pipe_stderr) { + pclose(pipe_stderr); + pipe_stderr = nullptr; + } + } + + // functions and variables + std::vector testArgs; // CLI arguments used + std::filesystem::path testBinary; // full path of this binary + std::filesystem::path testBinaryDir; // full directory of this binary + std::stringstream boost_log_buffer; // declare boost_log_buffer + std::stringstream cout_buffer; // declare cout_buffer + std::stringstream stdout_buffer; // declare stdout_buffer + std::stringstream stderr_buffer; // declare stderr_buffer + std::streambuf *sbuf; + FILE *pipe_stdout; + FILE *pipe_stderr; + + int + exec(const char *cmd) { + std::array buffer {}; + pipe_stdout = popen((std::string(cmd) + " 2>&1").c_str(), "r"); + pipe_stderr = popen((std::string(cmd) + " 2>&1").c_str(), "r"); + if (!pipe_stdout || !pipe_stderr) { + throw std::runtime_error("popen() failed!"); + } + while (fgets(buffer.data(), buffer.size(), pipe_stdout) != nullptr) { + stdout_buffer << buffer.data(); + } + while (fgets(buffer.data(), buffer.size(), pipe_stderr) != nullptr) { + stderr_buffer << buffer.data(); + } + int returnCode = pclose(pipe_stdout); + pipe_stdout = nullptr; + if (returnCode != 0) { + std::cout << "Error: " << stderr_buffer.str() << std::endl + << "Return code: " << returnCode << std::endl; + } + return returnCode; + } +}; diff --git a/tests/unit/test_sample.cpp b/tests/unit/test_sample.cpp new file mode 100644 index 0000000..cfa9a19 --- /dev/null +++ b/tests/unit/test_sample.cpp @@ -0,0 +1,5 @@ +#include + +TEST(HelloWorldTest, HelloWorld) { + EXPECT_TRUE(true); +} diff --git a/tests/utils.cpp b/tests/utils.cpp new file mode 100644 index 0000000..2ff1bfd --- /dev/null +++ b/tests/utils.cpp @@ -0,0 +1,21 @@ +/** + * @file utils.cpp + * @brief Utility functions + */ + +#include "utils.h" + +/** + * @brief Set an environment variable. + * @param name Name of the environment variable + * @param value Value of the environment variable + * @return 0 on success, non-zero error code on failure + */ +int +setEnv(const std::string &name, const std::string &value) { +#ifdef _WIN32 + return _putenv_s(name.c_str(), value.c_str()); +#else + return setenv(name.c_str(), value.c_str(), 1); +#endif +} diff --git a/tests/utils.h b/tests/utils.h new file mode 100644 index 0000000..f82de91 --- /dev/null +++ b/tests/utils.h @@ -0,0 +1,11 @@ +/** + * @file utils.h + * @brief Reusable functions for tests. + */ + +#pragma once + +#include + +int +setEnv(const std::string &name, const std::string &value); diff --git a/third-party/googletest b/third-party/googletest new file mode 160000 index 0000000..f8d7d77 --- /dev/null +++ b/third-party/googletest @@ -0,0 +1 @@ +Subproject commit f8d7d77c06936315286eb55f8de22cd23c188571 diff --git a/third-party/json b/third-party/json new file mode 160000 index 0000000..9cca280 --- /dev/null +++ b/third-party/json @@ -0,0 +1 @@ +Subproject commit 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03