diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /libs/usvfs/test/usvfs_global_test_runner | |
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem,
Proton/umu-run integration, and Flatpak packaging.
Key features:
- FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak)
- Proton/GE-Proton/umu-run launcher with env var forwarding
- Flatpak support (sandbox-aware VFS, NXM handler, umu-run)
- Wine prefix management UI
- Case-insensitive path resolution for Linux filesystems
- QSettings-safe INI handling (avoids Bethesda INI corruption)
- Portable instance support with auto-generated launcher scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/usvfs/test/usvfs_global_test_runner')
5 files changed, 584 insertions, 0 deletions
diff --git a/libs/usvfs/test/usvfs_global_test_runner/CMakeLists.txt b/libs/usvfs/test/usvfs_global_test_runner/CMakeLists.txt new file mode 100644 index 0000000..88ff0c2 --- /dev/null +++ b/libs/usvfs/test/usvfs_global_test_runner/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.16) + +find_package(GTest CONFIG REQUIRED) +find_package(Boost CONFIG REQUIRED COMPONENTS filesystem) + +# other executables +add_executable(usvfs_global_test + usvfs_global_test/usvfs_global_test.cpp +) +usvfs_set_test_properties(usvfs_global_test FOLDER usvfs_global_test) +target_link_libraries(usvfs_global_test PRIVATE gtest_utils GTest::gtest_main GTest::gmock Boost::filesystem) + +# actual test executable +add_executable(usvfs_global_test_runner + usvfs_global_test_fixture.cpp + usvfs_global_test_fixture.h + usvfs_global_test_runner.cpp +) +usvfs_set_test_properties(usvfs_global_test_runner FOLDER usvfs_global_test) +usvfs_target_link_usvfs(usvfs_global_test_runner) +target_link_libraries(usvfs_global_test_runner + PRIVATE test_utils gtest_utils GTest::gtest_main) +add_dependencies(usvfs_global_test_runner usvfs_global_test) diff --git a/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test/usvfs_global_test.cpp b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test/usvfs_global_test.cpp new file mode 100644 index 0000000..79c6c4c --- /dev/null +++ b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test/usvfs_global_test.cpp @@ -0,0 +1,216 @@ +#include <gtest/gtest.h> + +#define WIN32_LEAN_AND_MEAN +#include <Windows.h> + +#include <filesystem> +#include <fstream> + +#include <boost/filesystem.hpp> + +#include <gmock/gmock-matchers.h> +#include <gtest_utils.h> + +// anonymous class that allow tests to access parameters (currently only where the +// virtualized data folder is) +// +class +{ + bool initialize(int argc, char* argv[]) + { + // extract the data path + if (argc != 2) { + std::cerr << "missing data path, aborting\n"; + return false; + } + m_data = std::filesystem::path{argv[1]}; + + if (!exists(m_data)) { + std::cerr << "data path '" << m_data.string() << "'does not exist\n"; + return false; + } + + return true; + } + + friend int main(int argc, char* argv[]); + std::filesystem::path m_data; + +public: + const auto& data() const { return m_data; } +} parameters; + +// simple guard for handle +class HandleGuard +{ + HANDLE m_handle = INVALID_HANDLE_VALUE; + +public: + HandleGuard() = default; + HandleGuard(HANDLE handle) : m_handle{handle} {} + + ~HandleGuard() { close(); } + + operator HANDLE() { return m_handle; } + + void close() + { + if (m_handle != INVALID_HANDLE_VALUE) { + ::CloseHandle(m_handle); + m_handle = INVALID_HANDLE_VALUE; + } + } +}; + +// simple function to write content to a specified path +void write_content(const std::filesystem::path& path, const std::string_view content) +{ + std::ofstream ofs{path}; + ofs << content; +} + +TEST(BasicTest, SimpleTest) +{ + const auto data = parameters.data(); + + ASSERT_TRUE(exists(data)); + ASSERT_TRUE(exists(data / "docs")); + ASSERT_TRUE(exists(data / "empty")); + ASSERT_TRUE(exists(data / "docs" / "doc.txt")); + ASSERT_TRUE(exists(data / "readme.txt")); + + // should remove mods/mod1/info.txt + ASSERT_TRUE(exists(data / "info.txt")); + remove(data / "info.txt"); + ASSERT_FALSE(exists(data / "info.txt")); + + { + // retrieve the path of data using GetFinalPathNameByHandleW() to + // compare later on + std::filesystem::path dataPathFromHandle; + { + HandleGuard hdl = CreateFileW(data.c_str(), GENERIC_READ, 0, nullptr, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + ASSERT_NE(INVALID_HANDLE_VALUE, (HANDLE)hdl); + + WCHAR filepath[1024]; + const auto length = GetFinalPathNameByHandleW( + hdl, filepath, sizeof(filepath) / sizeof(WCHAR), FILE_NAME_NORMALIZED); + ASSERT_NE(0, length); + + dataPathFromHandle = std::filesystem::path(std::wstring(filepath, length)); + } + + const auto doc_txt = data / "docs" / "doc.txt"; + HandleGuard hdl = CreateFileW(doc_txt.c_str(), GENERIC_READ, 0, nullptr, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + ASSERT_NE(INVALID_HANDLE_VALUE, (HANDLE)hdl); + + WCHAR filepath[1024]; + const auto length = GetFinalPathNameByHandleW( + hdl, filepath, sizeof(filepath) / sizeof(WCHAR), FILE_NAME_NORMALIZED); + ASSERT_NE(0, length); + + ASSERT_EQ(dataPathFromHandle / "docs" / "doc.txt", + std::filesystem::path(std::wstring(filepath, length))); + } + + { + // retrieve the path of data using GetFileInformationByHandleEx() to + // compare later on + std::filesystem::path dataPathFromHandle; + { + HandleGuard hdl = CreateFileW(data.c_str(), GENERIC_READ, 0, nullptr, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + ASSERT_NE(INVALID_HANDLE_VALUE, (HANDLE)hdl); + + char buffer[4096]; + ASSERT_TRUE( + GetFileInformationByHandleEx(hdl, FileNameInfo, buffer, sizeof(buffer))); + auto* info = reinterpret_cast<FILE_NAME_INFO*>(buffer); + + dataPathFromHandle = std::filesystem::path( + std::wstring_view(info->FileName, info->FileNameLength / 2)); + } + + const auto info_txt = data / "readme.txt"; + HandleGuard hdl = CreateFileW(info_txt.c_str(), GENERIC_READ, 0, nullptr, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + ASSERT_NE(INVALID_HANDLE_VALUE, (HANDLE)hdl); + + char buffer[4096]; + ASSERT_TRUE( + GetFileInformationByHandleEx(hdl, FileNameInfo, buffer, sizeof(buffer))); + + auto* info = reinterpret_cast<FILE_NAME_INFO*>(buffer); + + ASSERT_EQ(dataPathFromHandle / "readme.txt", + std::filesystem::path( + std::wstring_view(info->FileName, info->FileNameLength / 2))); + } +} + +TEST(BoostFilesystemTest, BoostFilesystemTest) +{ + using namespace boost::filesystem; + + const path data{parameters.data().native()}; + + std::vector<std::string> contents; + for (recursive_directory_iterator it{data}; it != recursive_directory_iterator{}; + ++it) { + contents.push_back(relative(it->path(), data).string()); + } + ASSERT_THAT(contents, ::testing::UnorderedElementsAre( + ".gitkeep", "docs", "docs\\doc.txt", "docs\\subdocs", + "docs\\subdocs\\.gitkeep")); +} + +// see https://github.com/ModOrganizer2/modorganizer/issues/2039 for context +// +TEST(RedFileSystemTest, RedFileSystemTest) +{ + const auto data = parameters.data(); + + const auto storages_path = data / "r6" / "storages"; + const auto hudpainter_path = storages_path / "HUDPainter"; + + ASSERT_TRUE(exists(hudpainter_path / "DEFAULT.json")); + + // TEST.json does not exist, so will be created in overwrite - this mainly check that + // weakly_canonical returns the path under data/ and not the actual path under + // overwrite/ + // + // this relies on the hook for NtQueryInformationFile + // + ASSERT_FALSE(exists(hudpainter_path / "TEST.json")); + ASSERT_EQ(hudpainter_path / "TEST.json", + weakly_canonical(hudpainter_path / "TEST.json")); + write_content(hudpainter_path / "TEST.json", "{}"); +} + +TEST(SkipFilesTest, SkipFilesTest) +{ + const auto data = parameters.data(); + + // file in mod1 should have been skipped + ASSERT_FALSE(exists(data / "readme.skip")); + + // docs/doc.skip should come from data, not mods1/docs/doc.skip + ASSERT_CONTENT_EQ("doc.skip in data/docs", data / "docs" / "doc.skip"); + + // write to readme.skip should create a file in overwrite + write_content(data / "readme.skip", "readme.skip in overwrite"); +} + +int main(int argc, char* argv[]) +{ + testing::InitGoogleTest(&argc, argv); + + // initialize parameters from remaining arguments + if (!parameters.initialize(argc, argv)) { + return -1; + } + + return RUN_ALL_TESTS(); +} diff --git a/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_fixture.cpp b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_fixture.cpp new file mode 100644 index 0000000..4f6e672 --- /dev/null +++ b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_fixture.cpp @@ -0,0 +1,205 @@ +#include "usvfs_global_test_fixture.h" + +#include <filesystem> +#include <format> +#include <fstream> + +#include <gtest/gtest.h> + +#include <stringcast.h> +#include <test_helpers.h> +#include <usvfs.h> +#include <windows_sane.h> + +// find the path to the executable that contains gtest entries +// +std::filesystem::path path_to_usvfs_global_test() +{ + return test::path_of_test_bin( + test::platform_dependant_executable("usvfs_global_test", "exe")); +} + +// path to the fixture for the given test group +// +std::filesystem::path path_to_usvfs_global_test_figures(std::wstring_view group) +{ + return test::path_of_test_fixtures() / "usvfs_global_test" / group; +} + +// spawn the an hook version of the given +// +DWORD spawn_usvfs_hooked_process( + const std::filesystem::path& app, const std::vector<std::wstring>& arguments = {}, + const std::optional<std::filesystem::path>& working_directory = {}) +{ + using namespace usvfs::shared; + + std::wstring command = app; + std::filesystem::path cwd = working_directory.value_or(app.parent_path()); + std::vector<wchar_t> env; + + if (!arguments.empty()) { + command += L" " + boost::algorithm::join(arguments, L" "); + } + + STARTUPINFO si{0}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{0}; + +#pragma warning(push) +#pragma warning(disable : 6387) + + if (!usvfsCreateProcessHooked(nullptr, command.data(), nullptr, nullptr, FALSE, 0, + nullptr, cwd.c_str(), &si, &pi)) { + test::throw_testWinFuncFailed( + "CreateProcessHooked", + string_cast<std::string>(command, CodePage::UTF8).c_str()); + } + + WaitForSingleObject(pi.hProcess, INFINITE); + + DWORD exit = 99; + if (!GetExitCodeProcess(pi.hProcess, &exit)) { + test::WinFuncFailedGenerator failed; + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + throw failed("GetExitCodeProcess"); + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + +#pragma warning(pop) + + return exit; +} + +bool UsvfsGlobalTest::m_force_usvfs_logs = false; + +void UsvfsGlobalTest::ForceUsvfsLogs() +{ + m_force_usvfs_logs = true; +} + +class UsvfsGlobalTest::UsvfsGuard +{ +public: + UsvfsGuard(std::string_view instance_name = "usvfs_test", bool logging = false) + : m_parameters(usvfsCreateParameters(), &usvfsFreeParameters) + { + usvfsSetInstanceName(m_parameters.get(), instance_name.data()); + usvfsSetDebugMode(m_parameters.get(), false); + usvfsSetLogLevel(m_parameters.get(), LogLevel::Debug); + usvfsSetCrashDumpType(m_parameters.get(), CrashDumpsType::None); + usvfsSetCrashDumpPath(m_parameters.get(), ""); + + usvfsInitLogging(logging); + usvfsCreateVFS(m_parameters.get()); + } + + ~UsvfsGuard() { usvfsDisconnectVFS(); } + +private: + std::unique_ptr<usvfsParameters, decltype(&usvfsFreeParameters)> m_parameters; +}; + +UsvfsGlobalTest::UsvfsGlobalTest() + : m_usvfs{std::make_unique<UsvfsGuard>()}, + m_temporary_folder{test::path_of_test_temp()} +{ + std::string name{testing::UnitTest::GetInstance()->current_test_info()->name()}; + m_group = {name.begin(), name.end()}; +} + +UsvfsGlobalTest::~UsvfsGlobalTest() +{ + CleanUp(); +} + +std::filesystem::path UsvfsGlobalTest::ActualFolder() const +{ + return m_temporary_folder; +} + +std::filesystem::path UsvfsGlobalTest::ExpectedFolder() const +{ + return path_to_usvfs_global_test_figures(m_group) / "expected"; +} + +void UsvfsGlobalTest::CleanUp() const +{ + if (exists(m_temporary_folder)) { + remove_all(m_temporary_folder); + } +} + +void UsvfsGlobalTest::PrepareFileSystem() const +{ + // cleanup in case a previous tests failed to delete its stuff + CleanUp(); + + // copy fixtures + const auto fixtures = path_to_usvfs_global_test_figures(m_group) / "source"; + if (exists(fixtures)) { + copy(fixtures, m_temporary_folder, std::filesystem::copy_options::recursive); + } +} + +void UsvfsGlobalTest::SetUpOverwrite(bool force) const +{ + if (force && !exists(m_overwrite_folder)) { + create_directory(m_overwrite_folder); + } + + if (exists(m_overwrite_folder)) { + usvfsVirtualLinkDirectoryStatic(m_overwrite_folder.c_str(), m_data_folder.c_str(), + LINKFLAG_CREATETARGET | LINKFLAG_RECURSIVE); + } +} + +void UsvfsGlobalTest::PrepareMapping() const +{ + // should not be needed, but just to be safe + usvfsClearVirtualMappings(); + + if (!exists(m_data_folder)) { + throw std::runtime_error{ + std::format("data path missing at {}", m_data_folder.string())}; + } + + if (exists(m_mods_folder)) { + for (const auto& mod : std::filesystem::directory_iterator(m_mods_folder)) { + if (!is_directory(mod)) { + continue; + } + usvfsVirtualLinkDirectoryStatic(mod.path().c_str(), m_data_folder.c_str(), + LINKFLAG_RECURSIVE); + } + } + + // by default, only create overwrite if present + SetUpOverwrite(false); +} + +int UsvfsGlobalTest::Run() const +{ + PrepareMapping(); + + const auto res = spawn_usvfs_hooked_process( + path_to_usvfs_global_test(), {std::format(L"--gtest_filter={}.*", m_group), + L"--gtest_brief=1", m_data_folder.native()}); + + // TODO: try to do this with gtest itself? + if (m_force_usvfs_logs || res != 0) { + const auto log_path = test::path_of_test_bin(m_group + L".log"); + std::ofstream os{log_path}; + std::string buffer(1024, '\0'); + std::cout << "process returned " << std::hex << res << ", usvfs logs dumped to " + << log_path.string() << '\n'; + while (usvfsGetLogMessages(buffer.data(), buffer.size(), false)) { + os << " " << buffer.c_str() << "\n"; + } + } + + return res; +} diff --git a/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_fixture.h b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_fixture.h new file mode 100644 index 0000000..aeafc25 --- /dev/null +++ b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_fixture.h @@ -0,0 +1,82 @@ +#pragma once + +#include <filesystem> +#include <memory> + +#include <gtest/gtest.h> + +class UsvfsGlobalTest : public testing::Test +{ +public: + // enable log mode - this will generate USVFS log file for all tests regardless of + // success or failure (default is to only generate for failure) + // + static void ForceUsvfsLogs(); + +public: + UsvfsGlobalTest(); + + void SetUp() override { PrepareFileSystem(); } + + void TearDown() override { CleanUp(); } + + ~UsvfsGlobalTest(); + + // setup the overwrite folder + // + // if force is true, force creation of the overwrite folder even if not present, this + // is useful when the overwrite is initially empty and thus cannot be committed to git + // but need to contain files after the run + // + void SetUpOverwrite(bool force = true) const; + + // run the test, return the exit code of the google test process + // + int Run() const; + + // return the path to the folder containing the expected results + // + std::filesystem::path ActualFolder() const; + + // return the path to the folder containing the expected results + // + std::filesystem::path ExpectedFolder() const; + +private: + class UsvfsGuard; + + // always generate usvfs logs + static bool m_force_usvfs_logs; + + // prepare the filesystem by copying files and folders from the relevant fixtures + // folder to the temporary folder + // + // after this operations, the temporary folder will contain + // - a data folder + // - [optional] a mods folder containing a set of folders that should be mounted + // - [optional] an overwrite folder that should be mounted as overwrite + // + void PrepareFileSystem() const; + + // prepare mapping using the given set of paths + // + void PrepareMapping() const; + + // cleanup the temporary path + // + void CleanUp() const; + + // usvfs_guard + std::unique_ptr<UsvfsGuard> m_usvfs; + + // name of GTest group (first argument of the TEST macro) to run + std::wstring m_group; + + // path to the folder containing temporary files + std::filesystem::path m_temporary_folder; + + // path to the subfolder inside the temporary folder + std::filesystem::path m_data_folder = m_temporary_folder / "data"; + std::filesystem::path m_mods_folder = m_temporary_folder / "mods"; + std::filesystem::path m_overwrite_folder = m_temporary_folder / "overwrite"; +}; diff --git a/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_runner.cpp b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_runner.cpp new file mode 100644 index 0000000..2fa6cc8 --- /dev/null +++ b/libs/usvfs/test/usvfs_global_test_runner/usvfs_global_test_runner.cpp @@ -0,0 +1,58 @@ +#include <gtest/gtest.h> + +#include <gtest_utils.h> +#include <test_helpers.h> +#include <usvfs.h> + +#include "usvfs_global_test_fixture.h" + +TEST_F(UsvfsGlobalTest, BasicTest) +{ + ASSERT_EQ(0, Run()); + ASSERT_DIRECTORY_EQ(ExpectedFolder(), ActualFolder()); +} + +TEST_F(UsvfsGlobalTest, BoostFilesystemTest) +{ + ASSERT_EQ(0, Run()); + ASSERT_DIRECTORY_EQ(ExpectedFolder(), ActualFolder()); +} + +TEST_F(UsvfsGlobalTest, RedFileSystemTest) +{ + SetUpOverwrite(true); + ASSERT_EQ(0, Run()); + ASSERT_DIRECTORY_EQ(ExpectedFolder(), ActualFolder()); +} + +TEST_F(UsvfsGlobalTest, SkipFilesTest) +{ + SetUpOverwrite(true); + + usvfsAddSkipFileSuffix(L".skip"); + + ASSERT_EQ(0, Run()); + ASSERT_DIRECTORY_EQ(ExpectedFolder(), ActualFolder()); +} + +int main(int argc, char* argv[]) +{ + // load the USVFS DLL + // + const auto usvfs_dll = + test::path_of_usvfs_lib(test::platform_dependant_executable("usvfs", "dll")); + test::ScopedLoadLibrary loadDll(usvfs_dll.c_str()); + if (!loadDll) { + std::wcerr << L"ERROR: failed to load usvfs dll: " << usvfs_dll.c_str() << L", " + << GetLastError() << L"\n"; + return 3; + } + + testing::InitGoogleTest(&argc, argv); + + UsvfsGlobalTest::ForceUsvfsLogs(); + + usvfsInitLogging(false); + + return RUN_ALL_TESTS(); +} |
