From 9339bb06636d79cd70c633d1e76713f8c84d385f Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 1 Oct 2026 00:24:51 -0500 Subject: [PATCH] test(windows): build and run the test suite for Windows from WSL scripts/test-windows.sh cross-compiles ObsidianDragonTests.exe (and the fake_dragonxd stub) with mingw into build/windows-tests, reusing the toolchain and dependency sources from build/windows, and runs it from WSL through Windows interop (or wine). All tests pass on Windows. CMake: - Windows forced BUILD_TESTING OFF in the cache to keep curl's tests out, which also removed ObsidianDragonTests from every Windows build. Hide it from curl with a scoped variable instead, and restore our cache value afterwards (curl's cmake_dependent_option FORCE-writes it). Windows still defaults to OFF, so release builds are unchanged. - ObsidianDragonTests now depends on copy_langs: i18n.cpp includes the generated embedded/lang_*.h, so a tree that builds only the tests failed (latent on Linux too). - Link the Windows system libraries the tests need. Tests: - testTmpPath/fixtureDir/removeTree helpers: no hard-coded /tmp, a runtime fixture-dir override (the compiled-in path is a Linux one), and best-effort cleanup that can't abort the suite. - Close the explorer cache before deleting its directory (Windows can't delete an open file). - Compile out the address-book and wallet-index round-trips on Windows: getConfigDir() uses SHGetFolderPath there, which no environment variable redirects, so they would overwrite the real %APPDATA%\ObsidianDragon files. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 20 ++++++++- scripts/test-windows.sh | 76 +++++++++++++++++++++++++++++++++++ tests/test_phase4.cpp | 72 ++++++++++++++++++++++++--------- tools/fake_dragonxd/README.md | 6 +-- 4 files changed, 151 insertions(+), 23 deletions(-) create mode 100755 scripts/test-windows.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index ba9af95..0623697 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -155,6 +155,11 @@ if(DRAGONX_ENABLE_LITE_BACKEND) set(DRAGONX_LITE_BACKEND_READY ON) endif() +# Windows (mingw cross) builds skip the large test target unless asked for with -DBUILD_TESTING=ON +# (scripts/test-windows.sh does that in its own build/windows-tests tree), so release builds are unchanged. +if(WIN32 AND NOT DEFINED BUILD_TESTING) + set(BUILD_TESTING OFF CACHE BOOL "Build the testing tree.") +endif() include(CTest) # Output directories @@ -266,7 +271,11 @@ if(WIN32) set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE) set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(CURL_STATICLIB ON CACHE BOOL "" FORCE) - set(BUILD_TESTING OFF CACHE BOOL "" FORCE) + # Keep curl's own tests out, without switching off ObsidianDragonTests for every Windows build: + # hide BUILD_TESTING behind a normal variable while curl configures, then restore our cache value + # (curl's cmake_dependent_option(BUILD_TESTING ...) FORCE-writes the OFF it saw into the cache). + set(_dragonx_build_testing "${BUILD_TESTING}") + set(BUILD_TESTING OFF) set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE) set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE) set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) # Use Windows native SSL @@ -275,6 +284,8 @@ if(WIN32) set(CURL_ZLIB OFF CACHE BOOL "" FORCE) set(HTTP_ONLY ON CACHE BOOL "" FORCE) FetchContent_MakeAvailable(curl) + unset(BUILD_TESTING) + set(BUILD_TESTING "${_dragonx_build_testing}" CACHE BOOL "Build the testing tree." FORCE) set(CURL_LIBRARIES libcurl_static) set(CURL_INCLUDE_DIRS ${curl_SOURCE_DIR}/include ${curl_BINARY_DIR}/lib/curl) else() @@ -1233,6 +1244,13 @@ if(BUILD_TESTING) if(UNIX) target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS}) endif() + if(WIN32) + target_link_libraries(ObsidianDragonTests PRIVATE ws2_32 crypt32 wldap32 psapi iphlpapi shell32 ole32 advapi32 bcrypt) + endif() + # i18n.cpp includes the generated embedded/lang_*.h headers; a tree that builds only this target needs them too. + if(TARGET copy_langs) + add_dependencies(ObsidianDragonTests copy_langs) + endif() add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests) endif() diff --git a/scripts/test-windows.sh b/scripts/test-windows.sh new file mode 100755 index 0000000..7c14c0c --- /dev/null +++ b/scripts/test-windows.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Cross-compile the test suite (ObsidianDragonTests.exe) and the fake daemon (tools/fake_dragonxd) +# for Windows with mingw-w64, then run the tests: directly under WSL (Windows interop), else with +# wine if installed, else just build. +# +# scripts/test-windows.sh [--no-run] [--jobs N] +# +# Uses its own build/windows-tests tree (full-node config), so build/windows is left alone. Needs +# the mingw toolchain file that ./build.sh --win-release writes to build/windows/. Dependency sources +# already fetched into build/windows/_deps are reused, so nothing is downloaded twice. +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bd="$repo/build/windows-tests" +toolchain="$repo/build/windows/mingw-toolchain.cmake" +deps="$repo/build/windows/_deps" +run=true +jobs=8 # each test TU is large; several parallel mingw builds can exhaust RAM + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-run) run=false; shift ;; + --jobs) jobs="$2"; shift 2 ;; + -h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +if [[ ! -f "$toolchain" ]]; then + echo "missing $toolchain; run ./build.sh --win-release once to create the mingw toolchain" >&2 + exit 1 +fi + +src_args=() +for dep in sdl3 json tomlplusplus sqlite3 curl libwebp; do + if [[ -d "$deps/$dep-src" ]]; then + src_args+=("-DFETCHCONTENT_SOURCE_DIR_${dep^^}=$deps/$dep-src") + fi +done + +cmake -S "$repo" -B "$bd" \ + -DCMAKE_TOOLCHAIN_FILE="$toolchain" \ + -DCMAKE_BUILD_TYPE=Release \ + -DDRAGONX_USE_SYSTEM_SDL3=OFF \ + -DDRAGONX_BUILD_LITE=OFF \ + -DDRAGONX_ENABLE_LITE_BACKEND=OFF \ + -DBUILD_TESTING=ON \ + -DDRAGONX_BUILD_FAKE_DAEMON=ON \ + "${src_args[@]}" +cmake --build "$bd" --target ObsidianDragonTests fake_dragonxd -j "$jobs" + +exe="$bd/bin/ObsidianDragonTests.exe" +echo "built: $exe" +echo "built: $bd/fake_dragonxd/dragonxd.exe" +$run || exit 0 + +# The fixture path compiled into the binary is a Linux path; hand the Windows process its own. +# APPDATA/LOCALAPPDATA point at a sandbox so anything resolving them from the environment stays out +# of the real profile. (SHGetFolderPath-based paths ignore the environment; the tests that would +# write through those are compiled out on Windows.) +export DRAGONX_TEST_FIXTURE_DIR="$repo/tests/fixtures" +sandbox="$bd/test-appdata" +mkdir -p "$sandbox" +export APPDATA="$sandbox" LOCALAPPDATA="$sandbox" +if grep -qi microsoft /proc/version 2>/dev/null && [[ -e /proc/sys/fs/binfmt_misc/WSLInterop ]]; then + # /p: translate each to a Windows path for the Windows process + export WSLENV="${WSLENV:+$WSLENV:}DRAGONX_TEST_FIXTURE_DIR/p:APPDATA/p:LOCALAPPDATA/p" + cd "$bd/bin" + exec "$exe" +elif command -v wine >/dev/null 2>&1; then + export DRAGONX_TEST_FIXTURE_DIR="$(winepath -w "$DRAGONX_TEST_FIXTURE_DIR")" + cd "$bd/bin" + exec wine "$exe" +else + echo "not on WSL and no wine: copy $exe (and tests/fixtures) to Windows to run it" >&2 +fi diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 5f88379..be0df54 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -126,6 +126,29 @@ fs::path makeTempDir() return dir; } +// Best-effort cleanup. Windows can't delete a file that is still open, and the throwing remove_all +// would abort the whole suite over a leftover temp file. +void removeTree(const fs::path& path) +{ + std::error_code ec; + fs::remove_all(path, ec); +} + +// A named path under the platform temp dir (/tmp on Linux, %TEMP% on Windows, where "/tmp/..." +// would mean C:\tmp). +fs::path testTmpPath(const std::string& name) +{ + return fs::temp_directory_path() / name; +} + +// The fixture root: compiled in, unless DRAGONX_TEST_FIXTURE_DIR is set at runtime. The Windows +// build runs from WSL (scripts/test-windows.sh), where the compiled-in Linux path doesn't resolve. +std::string fixtureDir() +{ + const char* env = std::getenv("DRAGONX_TEST_FIXTURE_DIR"); + return (env && *env) ? std::string(env) : std::string(DRAGONX_TEST_FIXTURE_DIR); +} + void writeTestFile(const fs::path& path, const std::string& content) { std::ofstream output(path, std::ios::binary); @@ -723,7 +746,7 @@ void testConnectionConfig() EXPECT_EQ(cookieConfig.auth_source, AuthSource::Cookie); EXPECT_TRUE(cookieConfig.use_tls); - fs::remove_all(dir); + removeTree(dir); } void testPaymentUri() @@ -763,7 +786,7 @@ void testWalletFileProbe() { namespace fs = std::filesystem; using dragonx::util::probeWalletFile; - const fs::path dir = fs::path("/tmp/dragonx") / "probe"; + const fs::path dir = testTmpPath("dragonx") / "probe"; std::error_code ec; fs::create_directories(dir, ec); auto writef = [](const fs::path& p, const std::string& d) { std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size()); @@ -3292,7 +3315,7 @@ void testDaemonLifecycleAdapters() EXPECT_FALSE(fs::exists(dataDir / "peers.dat")); EXPECT_FALSE(fs::exists(dataDir / "fee_estimates.dat")); EXPECT_TRUE(fs::exists(dataDir / "wallet.dat")); - fs::remove_all(dataDir); + removeTree(dataDir); } // Pure console wrap layout / hit-test / selection (console_text_layout.h), driven by a @@ -4189,7 +4212,8 @@ void testExplorerBlockCache() file << legacy.dump(2); } - ExplorerBlockCache cache(databasePath.string(), legacyPath.string()); + auto cacheOwner = std::make_unique(databasePath.string(), legacyPath.string()); + ExplorerBlockCache& cache = *cacheOwner; EXPECT_TRUE(cache.ensureOpen()); EXPECT_EQ(cache.cachedBlockCount(), 2); @@ -4234,7 +4258,8 @@ void testExplorerBlockCache() EXPECT_EQ(reopened.cachedBlockCount(), 0); } - fs::remove_all(dir); + cacheOwner.reset(); // close the database first: Windows can't delete an open file + removeTree(dir); } void testTransactionHistoryCache() @@ -4345,7 +4370,7 @@ void testTransactionHistoryCache() EXPECT_EQ(cache.snapshotCount(), 0); } - fs::remove_all(dir); + removeTree(dir); } // GC: generating a new address changes the wallet-identity hash; the prior hash's snapshot @@ -4377,7 +4402,7 @@ void testTransactionHistoryCachePrunesOldWallets() auto loadedB = cache.load(idB, 100, "tip-b"); EXPECT_TRUE(loadedB.loaded); - fs::remove_all(dir); + removeTree(dir); } void testTransactionHistoryCacheRefreshApply() @@ -4443,7 +4468,7 @@ void testTransactionHistoryCacheRefreshApply() EXPECT_EQ(lastTxBlock, 333); EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast(1)); - fs::remove_all(dir); + removeTree(dir); } @@ -4753,7 +4778,7 @@ void testGeneratedResourceBehavior() EXPECT_TRUE(dragonx::resources::getEmbeddedResource("__missing_resource__") == nullptr); if (!dragonx::resources::hasEmbeddedResources()) { EXPECT_TRUE(themes[0].data == nullptr); - EXPECT_EQ(dragonx::resources::extractBundledThemes("/tmp/obsidian-dragon-empty-themes"), 0); + EXPECT_EQ(dragonx::resources::extractBundledThemes(testTmpPath("obsidian-dragon-empty-themes").string()), 0); } } @@ -6030,7 +6055,7 @@ void testLiteRolloutManifestLoader() { using namespace dragonx::wallet; // Missing file -> not present -> fail-open allowed. - auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json"); + auto missing = loadLiteRolloutManifestFromFile(testTmpPath("obsidian-rollout-missing-xyz.json").string()); EXPECT_FALSE(missing.present); { LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing; @@ -6038,7 +6063,7 @@ void testLiteRolloutManifestLoader() } // A valid manifest file parses into the expected fields. - const std::string path = "/tmp/obsidian-rollout-test.json"; + const std::string path = testTmpPath("obsidian-rollout-test.json").string(); { std::ofstream f(path); f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})"; @@ -6053,7 +6078,7 @@ void testLiteRolloutManifestLoader() std::remove(path.c_str()); // Malformed JSON -> present but invalid -> fail-open allowed. - const std::string badPath = "/tmp/obsidian-rollout-bad.json"; + const std::string badPath = testTmpPath("obsidian-rollout-bad.json").string(); { std::ofstream f(badPath); f << "{ this is not json"; } auto bad = loadLiteRolloutManifestFromFile(badPath); EXPECT_TRUE(bad.present); @@ -6098,7 +6123,7 @@ void testLiteWalletControllerRolloutGate() // ── xmrig updater pure core (util/xmrig_updater.h) — driven by the real release fixture ── static std::string readXmrigFixture() { - const std::string path = std::string(DRAGONX_TEST_FIXTURE_DIR) + "/xmrig/release_latest.json"; + const std::string path = fixtureDir() + "/xmrig/release_latest.json"; std::ifstream f(path, std::ios::binary); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } @@ -6241,7 +6266,7 @@ void testAtomicFileWrite() using dragonx::util::Platform; fs::path dir = fs::temp_directory_path() / "obsidian_atomic_test"; - fs::remove_all(dir); + removeTree(dir); const std::string target = (dir / "nested" / "settings.json").string(); // Creates parent dirs and writes content. @@ -6270,7 +6295,7 @@ void testAtomicFileWrite() EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none); #endif - fs::remove_all(dir); + removeTree(dir); } // Address checksum validation. Uses standard Base58Check / BIP173 Bech32 vectors — the @@ -6562,7 +6587,7 @@ void testXmrigSignatureVerify() // Reads any fixture file by repo-relative path under tests/fixtures/. static std::string readFixtureFile(const std::string& rel) { - std::ifstream f(std::string(DRAGONX_TEST_FIXTURE_DIR) + "/" + rel, std::ios::binary); + std::ifstream f(fixtureDir() + "/" + rel, std::ios::binary); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } @@ -6761,7 +6786,7 @@ void testXmrigLiveInstall() return; } using namespace dragonx::util; - const std::string dir = "/tmp/obsidian-xmrig-live-test"; + const std::string dir = testTmpPath("obsidian-xmrig-live-test").string(); std::error_code ec; std::filesystem::remove_all(dir, ec); XmrigUpdater up; @@ -7577,6 +7602,11 @@ void testAddressBookScope() EXPECT_TRUE(legacy.visibleInWallet("anything")); // --- Round-trip + migration + scope-aware dedup, isolated under a temp $HOME --- +#ifdef _WIN32 + // getConfigDir() uses SHGetFolderPath(CSIDL_APPDATA), which no env var redirects: this would + // overwrite the real %APPDATA%\ObsidianDragon\addressbook.json. + std::printf("[skip] testAddressBookScope round-trip (Windows config dir can't be redirected)\n"); +#else const char* oldHome = std::getenv("HOME"); std::string savedHome = oldHome ? oldHome : ""; fs::path tmp = fs::temp_directory_path() / @@ -7629,6 +7659,7 @@ void testAddressBookScope() if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); std::error_code ec; fs::remove_all(tmp, ec); +#endif // !_WIN32 } // Wallet-files metadata index (multi-wallet P1b): upsert change-detection, find, extra folders, @@ -7658,7 +7689,11 @@ void testWalletIndex() EXPECT_TRUE(!idx.removeExtraFolder("/some/dir")); // already gone } - // save + reload round-trip under a temp $HOME + // save + reload round-trip under a temp $HOME. Not on Windows: the config dir comes from + // SHGetFolderPath there, so this would overwrite the real %APPDATA%\ObsidianDragon\wallets.json. +#ifdef _WIN32 + std::printf("[skip] testWalletIndex round-trip (Windows config dir can't be redirected)\n"); +#else { const char* oldHome = std::getenv("HOME"); std::string savedHome = oldHome ? oldHome : ""; @@ -7693,6 +7728,7 @@ void testWalletIndex() if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); std::error_code ec2; fs::remove_all(tmp, ec2); } +#endif // !_WIN32 } int main() diff --git a/tools/fake_dragonxd/README.md b/tools/fake_dragonxd/README.md index 0b1c63b..d1511ab 100644 --- a/tools/fake_dragonxd/README.md +++ b/tools/fake_dragonxd/README.md @@ -61,10 +61,8 @@ Everything that matters for a test is appended to `/fake_dragonxd.event cmake -S . -B build/linux -DDRAGONX_BUILD_FAKE_DAEMON=ON cmake --build build/linux --target fake_dragonxd # -> build/linux/fake_dragonxd/dragonxd -# Windows (mingw cross, from Linux/WSL), with the toolchain ./build.sh --win-release writes; own tree -cmake -S . -B build/windows-tests -DCMAKE_TOOLCHAIN_FILE=build/windows/mingw-toolchain.cmake \ - -DDRAGONX_USE_SYSTEM_SDL3=OFF -DDRAGONX_BUILD_FAKE_DAEMON=ON -cmake --build build/windows-tests --target fake_dragonxd # -> build/windows-tests/fake_dragonxd/dragonxd.exe +# Windows (mingw cross, from Linux/WSL): builds it with the Windows test suite in build/windows-tests +scripts/test-windows.sh --no-run # -> build/windows-tests/fake_dragonxd/dragonxd.exe ``` The output goes to its own `fake_dragonxd/` directory so it never replaces the real `bin/dragonxd`.