# mkLogosModuleTests — build and run unit tests for a Logos module # # This integrates with logos-test-framework and the module build system. # Tests are built as a standalone executable using LogosTest.cmake, # with the SDK in mock mode and optional C library mocking. # # Usage in a module's flake.nix: # # checks.${system}.unit-tests = logos-module-builder.lib.mkLogosModuleTests { # src = ./.; # testDir = ./tests; # configFile = ./metadata.json; # flakeInputs = inputs; # mockCLibs = ["gowalletsdk"]; # optional # }; { nixpkgs, lib, common, parseMetadata, logos-cpp-sdk, logos-protocol, logos-qt-sdk, logos-plugin-qt ? null, logos-view-module, logos-test-framework }: let modulePreConfigure = import ./modulePreConfigure.nix { inherit lib; }; in { # Required: Path to the module source src, # Required: Path to the tests directory testDir, # Optional: Path to metadata.json configFile ? null, # Optional: All flake inputs — module dependencies resolved from metadata flakeInputs ? {}, # Optional: Additional flake inputs for external libraries (same format as mkLogosModule) externalLibInputs ? {}, # Optional: C libraries to mock (won't link the real lib) mockCLibs ? [], # Optional: Custom preConfigure hook preConfigure ? "", # Optional: Extra build inputs extraBuildInputs ? [], # Optional: Extra CMake flags extraCmakeFlags ? [], }: let forAllSystems = f: lib.genAttrs common.systems (system: f system); # Parse config if available (defaults must satisfy parseModuleConfig shape consumers). # # Per-system, because everything this file reads off it — nix_packages.runtime, # dependencies, go_static_lib_names — is platform-keyable, and all of it is # read inside `checks` below where the target IS known. The literal fallback # takes a platform too: it can never contain a `platforms` block, but leaving # one call site on the old shape would leave a permanently-unmigrated example # in the tree for the next person to copy. configFor = system: parseMetadata.parseModuleConfig { platform = parseMetadata.platformForSystem system; json = if configFile != null then builtins.readFile configFile else ''{"name":"unknown","version":"0.0.0"}''; }; checks = forAllSystems (system: let pkgs = common.mkPkgs system; config = configFor system; logosSdk = logos-cpp-sdk.packages.${system}.default; # Build-platform half of the SDK. logos-cpp-generator is invoked by BARE # NAME from a build phase (logos-plugin-qt/lib/buildPlugin.nix:145), so it # must run on the builder. Under cross, packages.x86_64-windows.default # carries no runnable generator at all -- logos-cpp-sdk/nix/bin.nix:39 # silently skips the mingw .exe -- hence "command not found". # # `logosSdk` deliberately stays TARGET-typed: it is ALSO the header and # CMake-package root passed to LOGOS_CPP_SDK_ROOT, and those must keep # coming from the Windows set. Splitting the two roles is the whole point; # pointing the headers at the build system would produce a build that # SUCCEEDS while linking the wrong architecture. # # buildSystemFor is the identity on every native system, so this is a # no-op off the Windows target. logosSdkBuild = logos-cpp-sdk.packages.${common.buildSystemFor system}.default; logosQtSdk = logos-qt-sdk.packages.${system}.default; # The Qt HOST RUNTIME a test binary links (LogosAPI and the provider # objects). logos-test-framework's LogosTest.cmake takes it from # LOGOS_QT_HOST_ROOT and from nowhere else; LOGOS_QT_SDK_ROOT is passed # alongside purely for the Qt-typed headers logos-qt-sdk alone ships. logosQtHost = logos-plugin-qt.packages.${system}.logos-qt-host; # The Qt glue generator (universal/cdylib/ui backends) — Qt code is # the Qt layer's product; logos-cpp-generator keeps Qt-free outputs. logosQtGenerator = logos-qt-sdk.packages.${common.buildSystemFor system}.logos-qt-generator; # The cdylib Qt-plugin glue generator lives in logos-plugin-qt (the Qt # plugin BACKEND owns the glue; the SDK does not). logos-qt-sdk still # ships an older copy of the SAME emitter, and calling that one is not a # compile error — it silently emits STALE glue. That is how a # host-services grant went undelivered while every build stayed green. logosQtHostGenerator = logos-plugin-qt.packages.${common.buildSystemFor system}.logos-qt-host-generator; # The VIEW plugin glue generator (`--backend ui`), from logos-view-module. # Needed HERE too, not just in the plugin build: compose below runs # autoCodegen, which for a `type: ui_qml` module is the ui backend. Before # the emitter moved, this path got it for free from logos-qt-generator. logosViewGenerator = logos-view-module.packages.${common.buildSystemFor system}.logos-view-generator; # Same pin as the generator: the emitted glue calls into this header, so # the two must never come from different revisions. See buildCppPlugin.nix. logosViewInclude = logos-view-module.packages.${common.buildSystemFor system}.include; logosProtocolPkg = logos-protocol.packages.${system}.default; testFramework = logos-test-framework.packages.${system}.default; # Resolve runtime packages from metadata (e.g. nlohmann_json) runtimePkgNames = config.nix_packages.runtime or []; runtimePkgs = builtins.filter (p: p != null) (map (name: pkgs.${name} or null ) runtimePkgNames); # Resolve module dependencies moduleInputs = lib.filterAttrs (n: _: builtins.elem n config.dependencies) flakeInputs; resolvedModuleDeps = lib.mapAttrs (_: input: if input ? packages.${system}.default then input.packages.${system}.default else input ) moduleInputs; # Copy include files from module deps depIncludeSetup = lib.concatMapStringsSep "\n" (name: let dep = resolvedModuleDeps.${name} or null; in if dep != null then '' if [ -d "${dep}/include" ]; then echo "Copying include files from ${name}..." cp -r "${dep}/include"/* ./generated_code/ 2>/dev/null || true fi '' else "" ) config.dependencies; # Resolve testDir to a relative path within the source tree. # testDir is typically a nix path like ./tests; we need to find # which subdirectory name it corresponds to. testDirName = builtins.baseNameOf (builtins.toString testDir); # Mocked C libraries are replaced at link time by the module's mock # sources, so the real external library must never be referenced — let # alone realized — for the test derivation. Resolving a mocked lib here # would force Nix to build the upstream library (e.g. a risc0/Metal crate # that can't compile in the sandbox) even though the test never links it. nonMockedExternalLibInputs = lib.filterAttrs (name: _: ! lib.elem name mockCLibs) externalLibInputs; resolvedExternalLibs = lib.mapAttrs (name: value: if builtins.isAttrs value && value ? input then value.input.packages.${system}.${value.packages.default or "default"} else value ) nonMockedExternalLibInputs; externalLibRpath = lib.concatMapStringsSep ":" (name: "${resolvedExternalLibs.${name}}/lib" ) (builtins.attrNames resolvedExternalLibs); # Loader search path for the test run. Test binaries execute in place during # buildPhase, so any runtime dependency that a real (non-mocked) library # dlopens by bare name — e.g. libpq pulled in by a module's C library — must # be reachable via the dynamic loader. Those deps are the runtime nix # packages declared in metadata (nix.packages.runtime) plus the resolved # external libraries. Linked deps already resolve via CMAKE_INSTALL_RPATH; # this covers the dlopen-by-bare-name case the rpath cannot. testRuntimeLibPath = lib.makeLibraryPath (runtimePkgs ++ lib.attrValues resolvedExternalLibs); userPreConfigure = if builtins.isFunction preConfigure then preConfigure { externalLibs = resolvedExternalLibs; } else preConfigure; preConfigureStr = modulePreConfigure.compose { inherit config; externalLibs = resolvedExternalLibs; userPre = userPreConfigure; fixDarwin = true; copyExternals = true; }; goLibsForTest = lib.filter (n: ! lib.elem n mockCLibs) config.go_static_lib_names; goCmakeTestFlags = lib.optionals (goLibsForTest != []) [ "-DLOGOS_MODULE_GO_STATIC_LIBS=${lib.concatStringsSep ";" goLibsForTest}" ]; in { unit-tests = pkgs.stdenv.mkDerivation { pname = "logos-${config.name}-tests"; version = config.version; src = src; nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsNoGuiHook logosSdkBuild logosQtGenerator logosQtHostGenerator logosViewGenerator ] ++ extraBuildInputs; buildInputs = with pkgs; [ qt6.qtbase qt6.qtremoteobjects logosSdk logosQtSdk logosQtHost logosProtocolPkg testFramework ] ++ runtimePkgs; dontUseCmakeConfigure = true; buildPhase = '' runHook preBuild # Set up generated code directory mkdir -p ./generated_code # Copy dependency includes ${depIncludeSetup} # Run logos-cpp-generator if metadata available ${lib.optionalString (configFile != null) '' if command -v logos-cpp-generator &>/dev/null; then echo "Running logos-cpp-generator..." logos-cpp-generator --metadata "${configFile}" --general-only --output-dir ./generated_code || true fi ''} # Custom preConfigure hook ${preConfigureStr} # CMake configure + build from within the source tree mkdir -p build && cd build cmake ../${testDirName} \ -DLOGOS_CPP_SDK_ROOT=${logosSdk} \ -DLOGOS_QT_SDK_ROOT=${logosQtSdk} \ -DLOGOS_QT_HOST_ROOT=${logosQtHost} \ -DLOGOS_PROTOCOL_ROOT=${logosProtocolPkg} \ -DLOGOS_VIEW_INCLUDE_DIR=${logosViewInclude} \ -DLOGOS_TEST_FRAMEWORK_ROOT=${testFramework} \ -DCMAKE_MODULE_PATH=${testFramework}/cmake \ ${lib.optionalString (externalLibRpath != "") "-DCMAKE_INSTALL_RPATH=${externalLibRpath} -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON"} \ ${lib.concatMapStringsSep " " (f: f) (goCmakeTestFlags ++ extraCmakeFlags)} cmake --build . --parallel $NIX_BUILD_CORES ${lib.optionalString (testRuntimeLibPath != "") '' # Make runtime dependencies (e.g. libpq) discoverable for real # libraries that dlopen them by bare name during the test run. export LD_LIBRARY_PATH="${testRuntimeLibPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export DYLD_LIBRARY_PATH="${testRuntimeLibPath}''${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ''} # Run all test binaries (unit tests first, integration tests last) echo "Running ${config.name} tests..." { find . -maxdepth 1 -type f -executable \( -name "*_tests" -o -name "*_test" \) ! -name "*integration*" find . -maxdepth 1 -type f -executable \( -name "*_tests" -o -name "*_test" \) -name "*integration*" } | while read bin; do echo "Executing: $bin" "$bin" done # Save build directory for installPhase (avoid global /tmp — sandbox issues) echo "$(pwd)" > "$TMPDIR/logos-test-build-dir-path" runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out/bin BUILD_DIR="$(cat "$TMPDIR/logos-test-build-dir-path" 2>/dev/null || echo build)" find "$BUILD_DIR" -maxdepth 1 -type f -executable \( -name "*_tests" -o -name "*_test" \) | while read bin; do cp "$bin" $out/bin/ done runHook postInstall ''; doCheck = false; }; } ); in checks