The plane added three runtime libraries that bundle-libs.sh deliberately does not bundle, so they have to be declared per distro by hand - and two hand-maintained lists drifting apart is the failure this guard exists to prevent. check_linux_package_deps.py parses the runner's CMake for every pkg-config module it links, follows target_link_libraries to prove each one actually reaches the binary, and requires a package name for it in every distro's depends list. It fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call naming several modules, options preceding the module name, and version constraints like mpv>=0.40 that would otherwise be read as a package nobody ships. The smoke job builds the three packages and reads the dependencies back out of the artifacts, deriving what to expect from build-packages.py rather than restating it - so a library is declared once and verified everywhere. That job is off by default, which is exactly why it must not carry its own copy of the list. The Linux native job names libwayland-dev and libegl-dev instead of riding GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own rationale. In CI the host-dependency guard runs once: the named step covers the staged bundle, and build-packages.py's internal run - which exists for by-hand packaging - is skipped. The smoke job also drops patchelf, which nothing invokes.
335 lines
15 KiB
Python
335 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Behavior tests for the Linux package dependency guard.
|
|
|
|
The first case reproduces the bug that motivated the guard: the native video
|
|
plane added wayland-client, wayland-egl and egl to the runner's link line while
|
|
the hand-maintained depends lists went untouched.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CHECKER = ROOT / "scripts/check_linux_package_deps.py"
|
|
LINUX = ROOT / "linux"
|
|
# Every file the checker reads, relative to the linux/ directory it is given.
|
|
FIXTURE_FILES = (
|
|
"runner/CMakeLists.txt",
|
|
"CMakeLists.txt",
|
|
"flutter/CMakeLists.txt",
|
|
"packaging/build-packages.py",
|
|
"packaging/bundle-libs.sh",
|
|
)
|
|
# The subset the checker parses for pkg_check_modules and the link graph. Each
|
|
# one carries targets the others cannot see, so none of them is optional.
|
|
CMAKE_INPUTS = ("runner/CMakeLists.txt", "CMakeLists.txt", "flutter/CMakeLists.txt")
|
|
|
|
|
|
# Pins the walk's reach: gtk+-3.0, mpv, epoxy, glib-2.0, gio-2.0 and the three the
|
|
# video plane added. A drop here means the parser stopped seeing something rather
|
|
# than that a link was removed.
|
|
FULL_WALK_SUMMARY = "(8 pkg-config links, 8 modules)"
|
|
|
|
# The success line has to name its own scope. A reader who takes it for a
|
|
# whole-binary check trusts it past the plugin link edges it never walked, so
|
|
# the wording is part of what this guard promises.
|
|
SUCCESS_LINE = "linux/runner CMake dependency checks passed"
|
|
|
|
|
|
class LinuxPackageDepsGuardTest(unittest.TestCase):
|
|
def _run(self, omit: tuple[str, ...] = (), **edits: str) -> subprocess.CompletedProcess[str]:
|
|
"""Copy the real linux/ inputs, apply edits, and check the copy."""
|
|
with tempfile.TemporaryDirectory(prefix="plezy-linux-deps-test-") as directory:
|
|
linux = Path(directory) / "linux"
|
|
for name in FIXTURE_FILES:
|
|
if name in omit:
|
|
continue
|
|
target = linux / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(edits.get(name, self._source(name)), encoding="utf-8")
|
|
return subprocess.run(
|
|
[sys.executable, str(CHECKER), str(linux)],
|
|
cwd=ROOT,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
def _source(self, name: str) -> str:
|
|
return (LINUX / name).read_text(encoding="utf-8")
|
|
|
|
def _mutate(self, name: str, old: str, new: str) -> str:
|
|
text = self._source(name).replace(old, new, 1)
|
|
self.assertNotEqual(text, self._source(name), f"fixture mutation no longer matches: {old!r}")
|
|
return text
|
|
|
|
def test_current_tree_passes(self) -> None:
|
|
result = self._run()
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn(SUCCESS_LINE, result.stdout)
|
|
# Naming the artifact-level check is how the blind spot stays findable.
|
|
self.assertIn("check-bundle-host-deps.py", result.stdout)
|
|
self.assertIn(FULL_WALK_SUMMARY, result.stdout)
|
|
|
|
def test_a_missing_cmake_input_is_named_rather_than_skipped(self) -> None:
|
|
"""Losing one of these used to shrink the walk instead of failing it.
|
|
|
|
`flutter` is defined in flutter/CMakeLists.txt and propagates GTK, GLIB
|
|
and GIO; drop the file and those modules leave the graph, so a depends
|
|
entry deleted with them goes unreported and the guard exits 0 having
|
|
checked less than it says it did.
|
|
"""
|
|
for name in CMAKE_INPUTS:
|
|
with self.subTest(missing=name):
|
|
result = self._run(omit=(name,))
|
|
|
|
self.assertEqual(result.returncode, 1, result.stdout)
|
|
self.assertIn(str(Path("linux") / name), result.stderr)
|
|
self.assertIn("is missing", result.stderr)
|
|
|
|
def test_every_module_of_a_multi_module_call_is_checked(self) -> None:
|
|
"""`pkg_check_modules(X REQUIRED IMPORTED_TARGET a b c)` is legal CMake naming three modules.
|
|
|
|
Every one of them must be checked: the guard has to reject the call once
|
|
wayland-cursor is added to it, not just read wayland-client and stop.
|
|
"""
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)",
|
|
"pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client wayland-cursor)",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 1, result.stdout)
|
|
self.assertIn("wayland-cursor", result.stderr)
|
|
|
|
def test_a_version_constrained_module_spec_still_names_its_module(self) -> None:
|
|
"""`mpv>=0.40` is a legal moduleSpec whose module name is `mpv`.
|
|
|
|
The guard must strip the version constraint and still walk the full tree,
|
|
rather than reporting `mpv>=0.40` as an undeclared package.
|
|
"""
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)",
|
|
"pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv>=0.40)",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn(FULL_WALK_SUMMARY, result.stdout)
|
|
|
|
# The three below are fail-open cases: each is legal CMake that a naive regex
|
|
# silently drops, leaving the guard to exit 0 while a library goes undeclared.
|
|
def test_a_comment_containing_a_paren_does_not_hide_the_rest_of_the_call(self) -> None:
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EGL)",
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE\n"
|
|
" # host EGL (never bundled)\n"
|
|
" PkgConfig::EGL\n"
|
|
")",
|
|
)
|
|
packages = self._mutate("packaging/build-packages.py", '"libegl1",\n', "")
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake, "packaging/build-packages.py": packages})
|
|
|
|
self.assertEqual(result.returncode, 1, result.stdout)
|
|
self.assertIn("links egl but the deb package", result.stderr)
|
|
|
|
def test_a_generator_expression_still_names_its_target(self) -> None:
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"PRIVATE PkgConfig::EGL)",
|
|
"PRIVATE $<LINK_ONLY:PkgConfig::EGL>)",
|
|
)
|
|
packages = self._mutate("packaging/build-packages.py", '"libegl1",\n', "")
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake, "packaging/build-packages.py": packages})
|
|
|
|
self.assertEqual(result.returncode, 1, result.stdout)
|
|
self.assertIn("links egl but the deb package", result.stderr)
|
|
|
|
def test_a_quoted_target_still_names_its_target(self) -> None:
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"PRIVATE PkgConfig::EGL)",
|
|
'PRIVATE "PkgConfig::EGL")',
|
|
)
|
|
packages = self._mutate("packaging/build-packages.py", '"libegl1",\n', "")
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake, "packaging/build-packages.py": packages})
|
|
|
|
self.assertEqual(result.returncode, 1, result.stdout)
|
|
self.assertIn("links egl but the deb package", result.stderr)
|
|
|
|
def test_pkg_check_modules_options_may_precede_the_module_name(self) -> None:
|
|
# CMake takes its options in any order; reading REQUIRED as the module
|
|
# name fails closed but sends the reader hunting a package nobody ships.
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)",
|
|
"pkg_check_modules(EGL IMPORTED_TARGET REQUIRED egl)",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn(FULL_WALK_SUMMARY, result.stdout)
|
|
|
|
def test_the_dependency_lists_before_the_video_plane_are_rejected(self) -> None:
|
|
# Verbatim the deb list as it stood while the runner already linked
|
|
# wayland-client, wayland-egl and egl: the shipped bug.
|
|
packages = self._mutate(
|
|
"packaging/build-packages.py",
|
|
'"libwayland-client0",\n "libwayland-cursor0",\n'
|
|
' "libwayland-egl1",\n "libegl1",\n',
|
|
"",
|
|
)
|
|
|
|
result = self._run(**{"packaging/build-packages.py": packages})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("wayland-client", result.stderr)
|
|
self.assertIn("wayland-egl", result.stderr)
|
|
self.assertIn("libegl1", result.stderr)
|
|
self.assertIn("can fail to start", result.stderr)
|
|
|
|
def test_a_bundled_module_needs_no_declared_dependency(self) -> None:
|
|
"""libmpv ships inside the package, so the walk must not demand a dep.
|
|
|
|
The runner links pkg-config `mpv` and always will; what changed is that
|
|
the library travels with us, because the plane needs the pinned
|
|
Wayland-enabled build. Declaring a host mpv would be the bug now.
|
|
"""
|
|
result = self._run()
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertNotIn("mpv", result.stderr)
|
|
# Still counted: a bundled module dropping out of the walk is a parser
|
|
# regression, and the summary is what would show it.
|
|
self.assertIn(FULL_WALK_SUMMARY, result.stdout)
|
|
|
|
def test_one_missing_distro_is_rejected(self) -> None:
|
|
# A dependency declared for deb but forgotten for rpm still ships broken
|
|
# on Fedora, so per-distro coverage is the unit, not per-library.
|
|
packages = self._mutate("packaging/build-packages.py", '"libglvnd-egl",\n', "")
|
|
|
|
result = self._run(**{"packaging/build-packages.py": packages})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("rpm package does not depend on 'libglvnd-egl'", result.stderr)
|
|
self.assertNotIn("deb package does not depend", result.stderr)
|
|
|
|
def test_pacman_shared_wayland_package_counts_for_both_modules(self) -> None:
|
|
# Arch has no separate libwayland-egl, so one entry has to satisfy two
|
|
# modules. Dropping it must fail for both rather than neither.
|
|
packages = self._mutate("packaging/build-packages.py", '"wayland",\n', "")
|
|
|
|
result = self._run(**{"packaging/build-packages.py": packages})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("links wayland-client but the pacman package", result.stderr)
|
|
self.assertIn("links wayland-egl but the pacman package", result.stderr)
|
|
|
|
def test_new_pkgconfig_link_without_a_package_mapping_is_rejected(self) -> None:
|
|
# The forward-looking half: the next library added to the runner has to
|
|
# name its runtime package before it can ship.
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EGL)",
|
|
"pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3)\n"
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EGL)\n"
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::PIPEWIRE)",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("libpipewire-0.3", result.stderr)
|
|
self.assertIn("RUNTIME_PACKAGES", result.stderr)
|
|
|
|
def test_a_grouped_link_is_read_past_the_first_target(self) -> None:
|
|
# CMake happily takes several targets in one call. A parse that stopped at
|
|
# the first would wave the rest through while they sat on the link line.
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EGL)",
|
|
"pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3)\n"
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EGL PkgConfig::PIPEWIRE)",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("libpipewire-0.3", result.stderr)
|
|
|
|
def test_a_transitive_link_through_an_internal_target_is_followed(self) -> None:
|
|
# wayland_protocols already hands PkgConfig::WAYLAND_CLIENT to whatever
|
|
# links it. A static library's dependencies land on the consumer's link
|
|
# line, so stopping at the internal target would miss a real dependency.
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"target_link_libraries(wayland_protocols PUBLIC PkgConfig::WAYLAND_CLIENT)",
|
|
"pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3)\n"
|
|
"target_link_libraries(wayland_protocols PUBLIC PkgConfig::WAYLAND_CLIENT "
|
|
"PkgConfig::PIPEWIRE)",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("libpipewire-0.3", result.stderr)
|
|
|
|
def test_dropping_the_direct_wayland_client_link_still_finds_it(self) -> None:
|
|
# The runner names wayland-client directly *and* gets it through
|
|
# wayland_protocols. Removing the direct link must not silence the guard,
|
|
# because the binary still links the library either way.
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)\n",
|
|
"",
|
|
)
|
|
packages = self._mutate("packaging/build-packages.py", '"libwayland-client0",\n', "")
|
|
|
|
result = self._run(
|
|
**{"runner/CMakeLists.txt": cmake, "packaging/build-packages.py": packages}
|
|
)
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("links wayland-client but the deb package", result.stderr)
|
|
|
|
def test_a_link_with_no_pkg_check_modules_is_rejected(self) -> None:
|
|
cmake = self._mutate(
|
|
"runner/CMakeLists.txt",
|
|
"pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)",
|
|
"# EGL declaration removed",
|
|
)
|
|
|
|
result = self._run(**{"runner/CMakeLists.txt": cmake})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("PkgConfig::EGL is linked into the runner", result.stderr)
|
|
|
|
def test_bundling_the_excluded_libraries_is_rejected(self) -> None:
|
|
# The guard's premise is that these come from the host. If bundle-libs.sh
|
|
# starts shipping them, the demand for a depends entry needs rethinking
|
|
# rather than silently continuing to hold.
|
|
bundle = self._mutate("packaging/bundle-libs.sh", r"libwayland.*\.so|", "")
|
|
|
|
result = self._run(**{"packaging/bundle-libs.sh": bundle})
|
|
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("no longer excludes", result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|