Files
logos-module-builder/lib/mkLogosModuleTests.nix
Dario Gabriel LipicarandClaude Opus 5 fe4f26cecf feat: build ui_qml glue with logos-view-generator, not logos-qt-generator
modulePreConfigure.nix switches the `--backend ui` invocation to
logos-view-generator (logos-view-module), which now owns that emitter and
the logos_ui_plugin_context.h it pairs with.

THE REASON THIS IS NOT COSMETIC. This builder pins logos-qt-sdk at 4a1104c,
which predates b7b82e5 — the commit that added the module teardown hook to
the ui emitter. At 4a1104c BOTH halves lack it, so every build has been
green and self-consistent, and the hook has NEVER REACHED A SINGLE SHIPPED
ui_qml MODULE. Measured on the same module, before and after, by loading the
built plugin with QPluginLoader and dumping its QMetaObject:

    BASELINE                      MIGRATED
      initLogos(LogosAPI*)          unloadFinished()      [signal]
                                    initLogos(LogosAPI*)
                                    int aboutToUnload()   [invokable]

So this delivers the hook for the first time rather than preserving it.

LOGOS_VIEW_INCLUDE_DIR is added BEFORE the qt-sdk root in
LogosModule.cmake, so the emitter and its header resolve from ONE pin. That
ordering is load-bearing until logos-qt-sdk stops installing its copy of
logos_ui_plugin_context.h — anything going through logos_module() is safe;
a hand-run cmake putting LOGOS_QT_SDK_ROOT first would silently get the
wrong header. Called out in the code rather than left implicit.

Blast radius is 7 modules, not one: package_manager_ui, chat_ui, wallet_ui,
test_uiqml_probe, test_fullapi_ui, calc_ui_cpp and templates/ui-qml-backend
all match ui_qml + interface:universal. None override aboutToUnload(), and
the base default is Synchronous, so every one answers 0 and no host waits —
the change is additive. Only package_manager_ui was built end to end.

Three assertions in tests/test-module-pre-configure.nix pin the binary
choice; repointing at logos-qt-generator fails them. Also corrects a comment
there that claimed "the ui backend still legitimately uses qt-sdk's".

7/7 checks green, 399 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:59:50 -03:00

296 lines
12 KiB
Nix

# 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