Files
plezy/scripts/checks/test_check_linux_package_deps.py
T
edde746 f622ba8efe chore(scripts): group scripts into checks, codegen, maestro and release subdirectories
scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
2026-08-17 01:40:54 +02:00

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[2]
CHECKER = ROOT / "scripts/checks/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()