aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile8
-rwxr-xr-xscripts/package-inventory15
-rw-r--r--tests/package-inventory/test_package_inventory.py155
3 files changed, 172 insertions, 6 deletions
diff --git a/Makefile b/Makefile
index 30a52d4..629c750 100644
--- a/Makefile
+++ b/Makefile
@@ -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()