Files
logos-module-builder/tests/test-static-extlib.nix
9d3b7cc1f5 feat(windows): give mkLogosModule an x86_64-windows target (#194)
* feat(windows): give mkLogosModule an x86_64-windows target

Routes every package-set construction in the builder through one helper,
common.mkPkgs, and teaches that helper the "x86_64-windows" pseudo-system.
This is the leverage point for the whole module ecosystem: modules do not
construct pkgs themselves, so fixing it here lets all ~40 of them target
Windows without each re-deriving the cross plumbing.

x86_64-windows cannot be produced by `import nixpkgs { system = ...; }` --
a cross derivation's `system` attribute is its BUILD platform, so it needs
localSystem/crossSystem plus the mingw overlays, which is exactly what
logos-nix.lib.mkWindowsPkgs wraps. logos-nix was already an input of this
flake but was never threaded into the lib; it now reaches common.nix via
lib/default.nix, and the pseudo-system is only advertised when it is
present, so a caller without it is unaffected.

Ten sites moved: common.nix, buildCppPlugin.nix x2, mkLogosQmlModule.nix
x2, mkLogosModuleTests.nix, mkLogosModule.nix x4. `systems` and
`forAllSystems` moved into the let block, since an attribute set is not
recursive and both now reference each other.

The Rust path keeps its rust-overlay via mkPkgsWith, which THROWS for a
Windows target rather than silently dropping the overlay and handing back a
package set that is not what the caller asked for. Rust modules on Windows
were already out of scope; this makes that explicit at eval time instead of
producing a subtly wrong build.

Verified: logos-capability-module evaluates unchanged on aarch64-darwin and
now also evaluates for x86_64-windows.

* feat(windows): split host tools from target artifacts in the builder

Follow-up to the x86_64-windows target: getting the pseudo-system to
EVALUATE was not enough, because several things the builder puts on the
build machine were being taken from the TARGET package set.

* common.buildSystemFor names the system a build for a target actually runs
  on (identity natively, x86_64-linux for x86_64-windows). Host tools now
  come from there.

* logos-cpp-sdk's default output serves two roles at once -- it carries the
  logos-cpp-generator BINARY and the target headers/CMake package. Under
  cross those must come from different package sets, so the 15 consumers
  are now split by role: everything landing in nativeBuildInputs (plus
  buildHeaders and the moduleLidl generator invocation) takes the new
  logosSdkBuild, while every -DLOGOS_CPP_SDK_ROOT / LOGOS_CPP_SDK_ROOT /
  buildInputs slot keeps the untouched target logosSdk. Getting this
  backwards is worse than the failure it fixes: it would SUCCEED and link
  the wrong architecture. mkLogosModuleTests is the proof case -- same
  derivation, logosSdkBuild in nativeBuildInputs, logosSdk in buildInputs.

  (The symptom was "logos-cpp-generator: command not found" rather than an
  exec-format error because logos-cpp-sdk/nix/bin.nix:39 guards the copy on
  the unsuffixed name with no else, so a mingw build silently ships an
  EMPTY bin/. Worth fixing there too.)

* pkgs.jq -> pkgs.buildPackages.jq: jq is target-typed as well and runs in
  preConfigure.

* extraCmakeFlags now prepend pkgs.logosQtCrossCmakeFlags, which point Qt at
  its host TOOL packages (repc et al). Absent -- and so empty -- natively,
  hence no isWindows guard. The symptom is misleading: CMake names
  Qt6RemoteObjects, but the target config resolves fine and it is
  Qt6RemoteObjectsTools that is missing.

capability-module now gets through evaluation, generation and Qt discovery.
It does NOT build yet: Qt 6.11.1 then hits a duplicate imported-target error
in Qt6EntryPointMinGW32Target.cmake, which is a Qt-on-MinGW CMake issue
rather than a Logos one.

* fix(windows): make logos_find_qt a macro, and pass the header contract

Two changes that together get a module cross-building.

1. logos_find_qt was a function(). Qt's mingw Qt6EntryPointMinGW32Target.cmake
   guards itself with a bare include_guard(), which CMake scopes to the most
   recent FUNCTION scope, while add_library(IMPORTED) creates a DIRECTORY-scoped
   target. So the guard variable died at endfunction() while the target
   survived, and the next find_package(Qt6) -- via logos-protocol's or
   logos-qt-sdk's find_dependency -- re-entered and hit "cannot create imported
   target EntryPointMinGW32".

   This is upstream Qt: QTCREATORBUG-32887, confirmed by a Qt maintainer,
   never fixed in qtbase (the file is byte-identical 6.7 through dev). Qt's own
   fix was consumer-side, converting a function to a macro for exactly this
   reason (qt-creator 9ded3246). Every other target file in that tree guards on
   TARGET existence, which is scope-proof.

   The two PARENT_SCOPE qualifiers had to go: under a macro they would have
   written to logos_module's CALLER.

2. Threads a per-module header contract through to the backend's buildHeaders,
   so a legacy module without a derived LIDL can still produce typed headers
   when the plugin cannot be introspected. See logos-plugin-qt.

* fix(windows): find and ship external libraries under mingw

LogosModule.cmake's EXT_LIB_NAMES listed only .dylib/.so/.a, so every
module declaring nix.external_libraries failed to cross-compile with
"External library '<x>' ... was not found in .../lib" -- which reads like
a staging bug rather than a missing filename spelling.

On Windows a shared library is TWO files: you LINK the import library
(lib<x>.dll.a under mingw) and SHIP the .dll. Both are now searched.

The runtime copy needed care of its own: the import library's name ends
in ".a", so the existing `NOT MATCHES "\\.a$"` test would classify it as
a static archive and skip the copy -- producing a plugin that links
cleanly and then fails to load with no DLL beside it. When a .dll.a or
.lib was linked, the companion .dll is resolved and copied instead, and
its absence is a hard error rather than a silent omission.

Unblocks logos-package-downloader-module and every other external-library
module under cross.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): re-pin the L1-L5 inputs to their merged revs

logos-nix (L1); logos-protocol, logos-module (L2); logos-cpp-sdk,
logos-plugin-qt, logos-plugin-core, nix-bundle-lgx, logos-design-system (L3);
logos-qt-sdk, nix-bundle-logos-module-install (L4); and logos-view-module-runtime
(L5) are all on their default branches now, so the lock can name the merged revs
instead of the pre-merge branch tips it was resolving against while those PRs
were open.

Deliberately NOT repinned, because they carry no Windows work and are not part
of this chain: rust-overlay, logos-standalone-app, logos-test-framework, and
logos-rust-sdk (which is pinned to an explicit rev in flake.nix).

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: update the systems assertion for the x86_64-windows pseudo-system

550c7c9 appended "x86_64-windows" to common.systems but left
tests/test-common.nix asserting four entries, so the unit-test derivation threw:

    error: FAIL systems has 4 entries: expected 4, got 5

This is NOT a consequence of re-pinning logos-nix. The list is

    [ 4 native ] ++ lib.optional (logos-nix != null) "x86_64-windows"

and flake.nix passes that input unconditionally, so the count has been 5 since
550c7c9 regardless of which logos-nix rev is locked -- verified by evaluating
common.systems at 1cc41fa, the commit before the re-pin, which also gives 5. The
only reason it surfaced now is that this branch had no CI run between 550c7c9
and the PR being opened.

Asserts membership of the pseudo-system as well as the count, so the two cannot
drift apart again silently, and records what makes the fifth entry a pseudo-
system: a cross derivation's `system` is its BUILD platform, so it evaluates
anywhere and realises on x86_64-linux.

Verified: nix build .#checks.aarch64-darwin.default -> 257 tests passed.
Co-authored-by: Cursor <cursoragent@cursor.com>

* test: assert the extlib copy contract against the code, not a comment

1cc41fa reflowed the sentence "static archives are linked in, no runtime copy
needed" onto two lines. tests/test-static-extlib.nix grepped for that exact
prose, so the check failed on a comment rewrap while the behaviour it claimed to
cover was untouched:

    PASS: .a names added to find_library NAMES list
    PASS: copy_if_different is guarded by NOT EXT_LIB_FILENAME MATCHES
    <exit 1>

Like the systems assertion, this predates the re-pin: it breaks on 1cc41fa's tree
regardless of which revs are locked, and only surfaced now because the branch had
no CI run between that commit and the PR.

Test 3 now asserts the contract instead of the prose -- EXT_RUNTIME_LIB stays
empty for a plain .a and the copy is guarded on it -- so a reflow cannot break it
and a behaviour change cannot slip past it.

Adds Test 3b for the hazard 1cc41fa actually introduced, which nothing covered: a
mingw IMPORT library lib<x>.dll.a ends in ".a", so the static-archive guard would
skip it and ship a plugin that links clean and then cannot load. The regex script
now pins both halves of that overlap -- .dll.a matches the narrow pattern AND the
broad one, a real .a matches only the broad one -- which is the whole reason the
.dll.a arm must be tested first.

Verified: nix build .#checks.aarch64-darwin.static-extlib passes.
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(deps): re-pin logos-standalone-app to the lock that matches its runtime

This flake pins logos-view-module-runtime itself and forces it on
logos-standalone-app via `follows`, so that bumping it for module testing needs
no standalone release. That is deliberate, but it means standalone gets a
view-module-runtime its OWN lock knows nothing about.

At 592af8aa that paired a new LogosQmlBridge with a pre-deferred-events
logos-qt-sdk (09365f5e), and the doc-tests failed to LINK:

    undefined reference to `LogosAPIClient::eventSubscriptionState(unsigned long long) const'
    undefined reference to `LogosAPIClient::onEventWhenAvailable(...)'
    undefined reference to `LogosAPIClient::pendingEventSubscriptions() const'
    undefined reference to `LogosAPIClient::whenObjectAvailable(...)'

288fec2 brings standalone's lock onto the same SDK layer this flake uses --
logos-qt-sdk c6be61d0 on both sides now -- so the forced runtime and the SDK it
needs agree. Nothing in this flake had to reach further into standalone's
dependency graph to achieve it.

Note this failure was NOT specific to the Windows work: it needed only the
view-module-runtime bump, which this branch happens to be the first to carry.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:07:50 -03:00

169 lines
8.2 KiB
Nix

# Integration tests for static library (.a) support in EXTERNAL_LIBS
# Verifies commit 3d3a6830: .a files are discoverable via find_library and the
# runtime copy step is skipped for static archives (linked at build time).
{ pkgs }:
let
logosModuleCmake = ../cmake/LogosModule.cmake;
# cmake -P script: find_library resolves a .a when no shared lib is present.
# LIB_DIR is injected via -D on the cmake command line.
findStaticScript = pkgs.writeText "find_static_test.cmake" ''
find_library(staticonly_PATH
NAMES libstaticonly.dylib libstaticonly.so staticonly.dylib staticonly.so
libstaticonly.a staticonly.a
PATHS ''${LIB_DIR} NO_DEFAULT_PATH)
if(NOT staticonly_PATH)
message(FATAL_ERROR "FAIL: find_library did not locate libstaticonly.a")
endif()
get_filename_component(FOUND_NAME "''${staticonly_PATH}" NAME)
if(NOT FOUND_NAME STREQUAL "libstaticonly.a")
message(FATAL_ERROR "FAIL: expected libstaticonly.a, got ''${FOUND_NAME}")
endif()
message(STATUS "FOUND: ''${staticonly_PATH}")
'';
# cmake -P script: find_library prefers a shared lib over .a when both exist.
findPreferSharedScript = pkgs.writeText "find_prefer_shared_test.cmake" ''
find_library(duallib_PATH
NAMES libduallib.dylib libduallib.so duallib.dylib duallib.so
libduallib.a duallib.a
PATHS ''${LIB_DIR} NO_DEFAULT_PATH)
if(NOT duallib_PATH)
message(FATAL_ERROR "FAIL: find_library found nothing in dual-lib dir")
endif()
get_filename_component(FOUND_NAME "''${duallib_PATH}" NAME)
if(FOUND_NAME STREQUAL "libduallib.a")
message(FATAL_ERROR "FAIL: find_library chose .a over shared lib (wrong preference)")
endif()
message(STATUS "PREFERRED: ''${duallib_PATH}")
'';
# cmake -P script: verify the .a$ MATCHES regex used in the copy guard.
regexScript = pkgs.writeText "regex_test.cmake" ''
foreach(name libfoo.a foo.a libbar.a)
if(NOT ''${name} MATCHES "\\.a$")
message(FATAL_ERROR "FAIL: ''${name} should match \\.a$ but did not")
endif()
endforeach()
foreach(name libfoo.so libfoo.dylib foo.so foo.dylib libfoo.so.1)
if(''${name} MATCHES "\\.a$")
message(FATAL_ERROR "FAIL: ''${name} should NOT match \\.a$ but did")
endif()
endforeach()
# A mingw IMPORT library ends in ".a" too, so \.a$ alone cannot tell it
# apart from a static archive -- which is why LogosModule.cmake tests
# \.dll\.a$ FIRST. Pin both halves of that: the import library matches the
# narrower pattern, and a real static archive does not.
foreach(name libfoo.dll.a foo.dll.a)
if(NOT ''${name} MATCHES "\\.dll\\.a$")
message(FATAL_ERROR "FAIL: ''${name} should match \\.dll\\.a$ but did not")
endif()
if(NOT ''${name} MATCHES "\\.a$")
message(FATAL_ERROR "FAIL: ''${name} must also match \\.a$ -- that overlap is the hazard")
endif()
endforeach()
foreach(name libfoo.a foo.a)
if(''${name} MATCHES "\\.dll\\.a$")
message(FATAL_ERROR "FAIL: ''${name} is a static archive, not an import library")
endif()
endforeach()
message(STATUS "All regex tests passed")
'';
sharedExt = if pkgs.stdenv.hostPlatform.isDarwin then "dylib" else "so";
in pkgs.runCommand "static-extlib-tests" {
nativeBuildInputs = [ pkgs.cmake ];
} ''
set -euo pipefail
echo "=== Static External Library (.a) Tests ==="
# -------------------------------------------------------------------
# Test 1: find_library NAMES list now includes .a entries
# (verifies the "prefer shared, fall back to static" comment added by commit)
# -------------------------------------------------------------------
grep -q 'prefer shared, fall back to static' ${logosModuleCmake}
echo "PASS: .a names added to find_library NAMES list (shared preferred, .a fallback)"
# -------------------------------------------------------------------
# Test 2: .a$ guard wraps copy_if_different (NOT EXT_LIB_FILENAME MATCHES)
# -------------------------------------------------------------------
grep -q 'NOT EXT_LIB_FILENAME MATCHES' ${logosModuleCmake}
echo "PASS: copy_if_different is guarded by NOT EXT_LIB_FILENAME MATCHES"
# -------------------------------------------------------------------
# Test 3: the copy step is skipped for static archives.
#
# Asserted against the CODE, not against a comment. The original form of this
# test grepped the prose "static archives are linked in", which said nothing
# about behaviour and broke the moment that sentence was reflowed onto two
# lines by the mingw import-library fix.
#
# The contract is: EXT_RUNTIME_LIB stays empty for a plain .a, and the copy is
# guarded on it, so nothing is copied for a static archive.
# -------------------------------------------------------------------
grep -q 'elseif(NOT EXT_LIB_FILENAME MATCHES' ${logosModuleCmake}
grep -q 'if(EXT_RUNTIME_LIB)' ${logosModuleCmake}
echo "PASS: a plain .a leaves EXT_RUNTIME_LIB empty, and the copy is guarded on it"
# -------------------------------------------------------------------
# Test 3b: a Windows IMPORT library must not be mistaken for a static
# archive. lib<x>.dll.a ends in ".a", so the guard above would skip it and
# ship a plugin with no runtime DLL beside it -- it links clean and then
# fails to load. The .dll.a / .lib arm has to be tested BEFORE the .a arm.
# -------------------------------------------------------------------
grep -q 'EXT_LIB_FILENAME MATCHES "\\\\.dll\\\\.a\$"' ${logosModuleCmake}
grep -q 'found no ' ${logosModuleCmake}
echo "PASS: an import library resolves its companion DLL, and its absence is fatal"
# -------------------------------------------------------------------
# Test 4: copy_if_different command is still present (not removed)
# -------------------------------------------------------------------
grep -q 'copy_if_different' ${logosModuleCmake}
echo "PASS: copy_if_different still present for shared library handling"
# -------------------------------------------------------------------
# Test 4b: a missing configured external library is a FATAL_ERROR, not a
# silent WARNING — a misconfigured EXTERNAL_LIBS / go-static / LINK_TARGETS
# must fail the build loudly rather than produce a broken plugin.
# -------------------------------------------------------------------
grep -q 'Refusing to build a plugin' ${logosModuleCmake}
echo "PASS: missing external library aborts the build (FATAL_ERROR)"
if grep -qE 'message\(WARNING "External library|message\(WARNING "Go static library|message\(WARNING "Target .* not found for linking' ${logosModuleCmake}; then
echo "FAIL: a not-found external/link library is still only a WARNING"
exit 1
fi
echo "PASS: no silent WARNING remains for not-found external/link libraries"
# -------------------------------------------------------------------
# Test 5: cmake find_library resolves .a when only static archive present
# -------------------------------------------------------------------
mkdir -p testdir_static/lib
touch testdir_static/lib/libstaticonly.a
cmake -DLIB_DIR="$PWD/testdir_static/lib" -P ${findStaticScript} 2>&1 | grep -q 'FOUND:'
echo "PASS: find_library resolves .a when only static archive is present"
# -------------------------------------------------------------------
# Test 6: cmake find_library prefers shared lib over .a when both exist
# -------------------------------------------------------------------
mkdir -p testdir_dual/lib
touch "testdir_dual/lib/libduallib.${sharedExt}"
touch testdir_dual/lib/libduallib.a
cmake -DLIB_DIR="$PWD/testdir_dual/lib" -P ${findPreferSharedScript} 2>&1 | grep -q 'PREFERRED:'
echo "PASS: find_library prefers shared lib over .a when both exist"
# -------------------------------------------------------------------
# Test 7: .a$ regex correctly identifies static archives vs shared libs
# -------------------------------------------------------------------
cmake -P ${regexScript} 2>&1 | grep -q 'All regex tests passed'
echo "PASS: .a\$ regex correctly classifies static archives and shared libs"
echo ""
echo "All static external library tests passed."
mkdir -p $out
echo "passed" > $out/results.txt
''