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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:24:51 -05:00
co-authored by Claude Opus 5.5
parent 7b6e8a8a8e
commit 9339bb0663
4 changed files with 151 additions and 23 deletions
+19 -1
View File
@@ -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()
+76
View File
@@ -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
+54 -18
View File
@@ -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<ExplorerBlockCache>(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<size_t>(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<char>(f)), std::istreambuf_iterator<char>());
}
@@ -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<char>(f)), std::istreambuf_iterator<char>());
}
@@ -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()
+2 -4
View File
@@ -61,10 +61,8 @@ Everything that matters for a test is appended to `<datadir>/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`.