diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-14 22:32:23 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-14 22:32:23 -0500 |
| commit | 8661faa4ce8f62dce05bd5aaf29948194a8c01d3 (patch) | |
| tree | a2d9b1c12f9f33e69a0eafbfa63e92e8468a57fe | |
| parent | 9e426241e1632fe9d5c9ee6c7cb60d11c13ac1fa (diff) | |
| download | archsetup-8661faa4ce8f62dce05bd5aaf29948194a8c01d3.tar.gz archsetup-8661faa4ce8f62dce05bd5aaf29948194a8c01d3.zip | |
test(scripts): lock package-inventory behavior with characterization tests
package-inventory compares archsetup's declared packages against the live system but had no tests, so a future archsetup edit (a new for-loop shape, a renamed install helper) could silently break the extraction.
I added two env seams so the script is testable without the real system. PKGINV_ARCHSETUP points the extractor at a fixture installer, PKGINV_PACMAN swaps in a fake pacman serving controlled query output. Both default to the real targets, so normal use is unchanged, and the seams match the env-override pattern audit-packages.sh already uses.
The 7 tests pin the extraction (direct calls, for-loop lists, variable-arg skip) and both diff directions against the fixture, with no network or real pacman db. I also added a make package-diff target so the tool is reachable alongside the test targets.
| -rw-r--r-- | Makefile | 8 | ||||
| -rwxr-xr-x | scripts/package-inventory | 15 | ||||
| -rw-r--r-- | tests/package-inventory/test_package_inventory.py | 155 |
3 files changed, 172 insertions, 6 deletions
@@ -5,7 +5,7 @@ # (https://git.cjennings.net/dotfiles.git). Run them from there: # cd ~/.dotfiles && make stow|restow|reset|unstow|import <de> -.PHONY: help deps test-unit test test-keep test-vm-base +.PHONY: help deps test-unit test test-keep test-vm-base package-diff # Default target - show help help: @@ -17,6 +17,7 @@ help: @echo " test Run full VM test suite (creates base VM if needed)" @echo " test-keep Run test and keep VM running for manual testing" @echo " test-vm-base Create base VM only (runs archangel)" + @echo " package-diff Compare archsetup's declared packages vs this system" @echo "" @echo "Dotfile stow operations now live in the dotfiles repo:" @echo " cd ~/.dotfiles && make stow|restow|reset|unstow|import <de>" @@ -64,3 +65,8 @@ test-keep: bash scripts/testing/create-base-vm.sh; \ fi @bash scripts/testing/run-test.sh --keep + +# Compare the packages archsetup declares against what's installed here. +# Shows declared-but-missing and installed-but-undeclared (AUR vs official). +package-diff: + @bash scripts/package-inventory diff --git a/scripts/package-inventory b/scripts/package-inventory index 4742645..2dda44b 100755 --- a/scripts/package-inventory +++ b/scripts/package-inventory @@ -8,7 +8,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -ARCHSETUP="$SCRIPT_DIR/archsetup" + +# Seams for testing: PKGINV_ARCHSETUP points the extractor at a fixture +# installer; PKGINV_PACMAN swaps in a fake pacman serving controlled +# -Qqe/-Qq/-Qqen/-Qqem output. Both default to the real targets. +ARCHSETUP="${PKGINV_ARCHSETUP:-$SCRIPT_DIR/archsetup}" +PACMAN="${PKGINV_PACMAN:-pacman}" # Colors RED='\033[0;31m' @@ -66,22 +71,22 @@ extract_archsetup_packages() { # Get packages on live system # ============================================================================ get_system_packages_explicit() { - pacman -Qqe | sort -u # explicitly installed + "$PACMAN" -Qqe | sort -u # explicitly installed } get_system_packages_all() { - pacman -Qq | sort -u # all installed (including deps) + "$PACMAN" -Qq | sort -u # all installed (including deps) } # ============================================================================ # Categorize system packages # ============================================================================ get_system_native() { - pacman -Qqen | sort -u # native (official repos) + "$PACMAN" -Qqen | sort -u # native (official repos) } get_system_foreign() { - pacman -Qqem | sort -u # foreign (AUR/manual) + "$PACMAN" -Qqem | sort -u # foreign (AUR/manual) } # ============================================================================ diff --git a/tests/package-inventory/test_package_inventory.py b/tests/package-inventory/test_package_inventory.py new file mode 100644 index 0000000..b48e2ab --- /dev/null +++ b/tests/package-inventory/test_package_inventory.py @@ -0,0 +1,155 @@ +"""Tests for scripts/package-inventory. + +package-inventory compares the packages archsetup declares against what is +installed on the live system. It extracts archsetup's packages — direct +pacman_install/aur_install calls plus `for software in ...` loop lists, +skipping variable arguments — then diffs them against pacman's view of the +system in both directions: declared-but-not-installed, and +installed-but-not-declared (split into AUR/foreign vs official). + +Tests run the real script against a fixture installer snippet and a fake +pacman serving controlled -Qqe/-Qq/-Qqen/-Qqem output. No real pacman db, +no network. The seams are env overrides: + PKGINV_ARCHSETUP path to the installer script to extract from + PKGINV_PACMAN pacman binary to query the system with + +Run from repo root: + make test-unit + (or python3 -m unittest tests.package-inventory.test_package_inventory) +""" + +import os +import re +import shutil +import stat +import subprocess +import tempfile +import unittest + +ANSI = re.compile(r"\x1b\[[0-9;]*m") # summary colorizes counts; strip for matching + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts/package-inventory") + +# Installer fixture. Declared set after extraction (sorted, unique): +# pkg-common, pkg-missing, aur-common, loop-a, loop-b, loop-c +# The bare "$software" lines are loop plumbing, not packages, and drop out. +FIXTURE = """ +pacman_install pkg-common # installed -> in both +pacman_install pkg-missing # not installed anywhere +aur_install aur-common # installed (foreign) -> in both +for software in loop-a loop-b \\ + loop-c; do + pacman_install "$software" +done +aur_install "$software" # variable -> not a package +""" + +# Fake-system state, keyed by the pacman query flag the script uses. +# -Qqe explicitly installed -Qq all installed (incl deps) +# -Qqen native (official) -Qqem foreign (AUR/manual) +# loop-c and pkg-missing are absent from the system; extra-native and +# extra-foreign are installed but undeclared. +SYSTEM = { + "-Qqe": ["pkg-common", "aur-common", "loop-a", "loop-b", + "extra-native", "extra-foreign"], + "-Qq": ["pkg-common", "aur-common", "loop-a", "loop-b", + "extra-native", "extra-foreign", "dep-x"], + "-Qqen": ["pkg-common", "loop-a", "loop-b", "extra-native"], + "-Qqem": ["aur-common", "extra-foreign"], +} + + +class InventoryHarness(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="pkg-inv-test-") + self.fixture = os.path.join(self.tmp, "installer-snippet") + with open(self.fixture, "w") as f: + f.write(FIXTURE) + + # Fake pacman: dispatch on the query flag, print the fixed set. + cases = "".join( + f' {flag}) printf "%s\\n" {" ".join(pkgs)} ;;\n' + for flag, pkgs in SYSTEM.items() + ) + self.fake_pacman = os.path.join(self.tmp, "pacman") + self._stub(self.fake_pacman, f'case "$1" in\n{cases} *) exit 1 ;;\nesac\n') + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _stub(self, path, body): + with open(path, "w") as f: + f.write("#!/bin/sh\n" + body) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR) + + def run_inv(self, *args): + env = os.environ.copy() + env["PKGINV_ARCHSETUP"] = self.fixture + env["PKGINV_PACMAN"] = self.fake_pacman + return subprocess.run( + [SCRIPT, *args], + env=env, capture_output=True, text=True, timeout=20, + ) + + +class TestSummary(InventoryHarness): + def test_summary_counts_extraction_and_both_diffs(self): + r = self.run_inv("--summary") + self.assertEqual(r.returncode, 0, msg=r.stderr) + out = ANSI.sub("", r.stdout) + # 6 declared proves the for-loop list parsed and "$software" dropped. + self.assertRegex(out, r"Archsetup declares:\s+6\b") + self.assertRegex(out, r"In common:\s+4\b") + self.assertRegex(out, r"In archsetup, not system:\s+2\b") + self.assertRegex(out, r"On system, not archsetup:\s+2\b") + + +class TestDiffLists(InventoryHarness): + def test_archsetup_only_lists_declared_but_uninstalled(self): + r = self.run_inv("--archsetup-only") + self.assertEqual(r.returncode, 0, msg=r.stderr) + got = set(r.stdout.split()) + # loop-c proves a loop-extracted package flows through the diff. + self.assertEqual(got, {"pkg-missing", "loop-c"}) + + def test_system_only_lists_installed_but_undeclared(self): + r = self.run_inv("--system-only") + self.assertEqual(r.returncode, 0, msg=r.stderr) + got = set(r.stdout.split()) + self.assertEqual(got, {"extra-native", "extra-foreign"}) + + def test_variable_arg_is_not_treated_as_a_package(self): + r = self.run_inv("--archsetup-only") + self.assertNotIn("software", r.stdout) + + +class TestFullReport(InventoryHarness): + def test_full_report_splits_undeclared_by_source(self): + r = self.run_inv() + self.assertEqual(r.returncode, 0, msg=r.stderr) + self.assertIn("In archsetup but NOT installed", r.stdout) + self.assertIn("pkg-missing", r.stdout) + self.assertIn("loop-c", r.stdout) + self.assertIn("On system but NOT in archsetup", r.stdout) + self.assertIn("AUR/foreign", r.stdout) + self.assertIn("extra-foreign", r.stdout) + self.assertIn("Official repos", r.stdout) + self.assertIn("extra-native", r.stdout) + + +class TestCli(InventoryHarness): + def test_help_describes_modes(self): + r = self.run_inv("--help") + self.assertEqual(r.returncode, 0, msg=r.stderr) + self.assertIn("Usage", r.stdout) + self.assertIn("--archsetup-only", r.stdout) + + def test_unknown_option_errors(self): + r = self.run_inv("--bogus") + self.assertNotEqual(r.returncode, 0) + self.assertIn("Unknown option", r.stderr) + + +if __name__ == "__main__": + unittest.main() |
