#!/usr/bin/env bats # tests/pkg-inventory.bats — black-box tests for scripts/pkg-inventory.sh (Refs #51) # # Contract: `install.sh` installs desired-minus-installed from packages/*.pkgs. This tool walks # the other direction — installed-minus-declared — because a manifest the machine has quietly # outgrown cannot reproduce the machine on a rebuild. Only stdout, stderr and the exit status are # observed: 0 no drift, 1 drift (hand-installed packages undeclared), 2 usage, 3 sync databases # unavailable so repo/AUR classification is impossible. # # Every pacman/flatpak call hits a stub: nothing here may query the real package database, and a # real pacman on PATH would make the fixture answers depend on the workstation. setup() { TEST_TMP="$(mktemp -d)" SCRIPT="$BATS_TEST_DIRNAME/../scripts/pkg-inventory.sh" REPO="$TEST_TMP/repo" mkdir -p "$REPO/packages" "$TEST_TMP/bin" # --- manifests: common + host(box) + aur ------------------------------------ cat > "$REPO/packages/common.pkgs" <<'EOF' # common desired state ghostty zoxide vlc # declared, deliberately NOT installed below EOF cat > "$REPO/packages/box.pkgs" <<'EOF' neovim EOF cat > "$REPO/packages/aur.pkgs" <<'EOF' typora # AUR-only desired state EOF # --- pacman log: install day 2026-01-01, hand installs from 2026-03 ---------- cat > "$TEST_TMP/pacman.log" <<'EOF' [2026-01-01T10:00:00-0800] [PACMAN] Running 'pacman --sysroot /tmp/calamares-root-x -S base' [2026-01-01T10:00:01-0800] [ALPM] installed filesystem (2026.01.01-1) [2026-01-01T10:00:02-0800] [ALPM] installed base (3-1) [2026-01-01T10:00:03-0800] [ALPM] installed libfoo (1.0-1) [2026-03-04T09:00:00-0800] [ALPM] installed bun (1.2-1) [2026-03-04T09:30:00-0800] [ALPM] upgraded filesystem (2026.01.01-1 -> 2026.03.01-1) [2026-03-05T09:00:00-0800] [ALPM] installed nirimod-git (r7-1) [2026-03-07T09:00:00-0800] [ALPM] installed libstray (1.0-1) [2026-03-06T09:00:00-0800] [ALPM] removed base (3-1) EOF # --- installed state -------------------------------------------------------- # ghostty/zoxide/neovim/typora declared. libfoo: explicit, arrived with the installer. # bun/nirimod-git: installed by hand afterwards, undeclared. python-hatchling and libstray: # installed as dependencies and now unrequired (pacman -Qtdq). libstray arrived AFTER the # baseline, so the date filter alone cannot keep it out of drift — only the explicit query can. printf '%s\n' base bun filesystem ghostty libfoo libstray neovim nirimod-git typora zoxide > "$TEST_TMP/local-all" printf '%s\n' bun ghostty libfoo neovim nirimod-git typora zoxide > "$TEST_TMP/local-explicit" printf '%s\n' python-hatchling libstray > "$TEST_TMP/orphans" # nirimod-git is deliberately ABSENT here: no sync db can serve it, which is the fact that # routes it to aur.pkgs in the test below. printf '%s\n' bun ghostty neovim typora zoxide libfoo vlc python-hatchling base filesystem > "$TEST_TMP/sync-all" # Four flag combinations are the script's whole interface to the package manager; the stub # answers those and refuses anything else, so an accidental new query fails loudly. cat > "$TEST_TMP/bin/pacman" <<'STUB' #!/usr/bin/env bash case "$1" in -Sl) [ "${STUB_SYNC_EMPTY:-0}" = 1 ] && exit 1 sed 's/^/syncdb /' "$SYNC_ALL" ;; -Qqe) cat "$LOCAL_EXPLICIT" ;; -Qtdq) cat "$ORPHANS"; [ -s "$ORPHANS" ] ;; -Qq) cat "$LOCAL_ALL" ;; *) echo "stub pacman: unhandled args: $*" >&2; exit 64 ;; esac STUB cat > "$TEST_TMP/bin/flatpak" <<'STUB' #!/usr/bin/env bash [ "${STUB_NO_FLATPAK:-0}" = 1 ] && exit 127 printf '%s\n' com.discordapp.Discord STUB chmod +x "$TEST_TMP/bin/pacman" "$TEST_TMP/bin/flatpak" export PATH="$TEST_TMP/bin:$PATH" export PKGINV_REPO="$REPO" PKGINV_HOST=box PKGINV_LOG="$TEST_TMP/pacman.log" export LOCAL_ALL="$TEST_TMP/local-all" LOCAL_EXPLICIT="$TEST_TMP/local-explicit" export ORPHANS="$TEST_TMP/orphans" SYNC_ALL="$TEST_TMP/sync-all" } teardown() { rm -rf "$TEST_TMP"; } # section RE_START RE_END — the report from RE_START's line up to (not including) RE_END, so an # assertion proves a name landed in a specific section instead of merely appearing somewhere in the # report. Start inclusive (the count and the names sit on the label line), terminator exclusive (a # plain sed range would leak the next section's first line). Anchoring matters: an unanchored start # runs the range to end-of-output, silently turning every absence assertion into a vacuous pass — # exactly how four of these tests were passing before this helper was fixed. section() { printf '%s\n' "$output" | awk -v s="$1" -v e="$2" '$0 ~ e {on=0} $0 ~ s {on=1} on'; } ADDITIONS() { section "^undeclared additions" "^installer-provided"; } @test "header names the host and takes the baseline from the oldest install line" { run "$SCRIPT" [ "$status" -eq 1 ] # the fixture has drift by design [[ "$output" == *"pkg-inventory host=box"* ]] [[ "$output" == *"baseline=2026-01-01"* ]] } @test "hand-installed packages are reported as additions" { run "$SCRIPT" [[ "$output" == *"undeclared additions (declare these): 2"* ]] s="$(ADDITIONS)" [[ "$s" == *bun* ]] [[ "$s" == *nirimod-git* ]] } @test "a package the installer provided is not reported as an addition" { run "$SCRIPT" additions="$(ADDITIONS)" [[ "$additions" != *libfoo* ]] [[ "$(section "^installer-provided" "^declared,")" == *libfoo* ]] # The folded block must end in a newline. Without one, the following label is glued onto the # last folded line and every section after it vanishes from a sliced report. [[ "$(section "^installer-provided" "^declared,")" != *declared,* ]] } @test "AUR-only packages are separated from sync-repo packages" { run "$SCRIPT" # nirimod-git is missing from the sync-DB fixture, so only a helper can install it: it must be # routed to aur.pkgs, not to a pacman line that would fail on a fresh box. Bash `*` crosses # newlines, so the absence check runs on one line rather than on the whole section. repo_line="$(section '^ repo ' '^ aur ')" [[ "$repo_line" == *"repo (sync dbs): bun"* ]] [[ "$repo_line" != *nirimod-git* ]] [[ "$(section '^ aur ' '^installer-provided')" == *"aur (needs a helper): nirimod-git"* ]] } @test "declared-but-missing packages are listed for install.sh" { run "$SCRIPT" [[ "$output" == *"declared, not installed"* ]] [[ "$(section "^declared," "^orphans")" == *vlc* ]] } @test "orphans are named on their own line, not mixed into the drift answer" { run "$SCRIPT" [[ "$(section "^orphans" "^flatpak")" == *python-hatchling* ]] [[ "$(ADDITIONS)" != *python-hatchling* ]] } @test "a dependency installed after the baseline is never drift, orphan or not" { # Drift means `pacman -Qqe` — you asked for this one. libstray arrived as a dependency # after install day and is now unrequired, so the baseline filter does not exclude it: # only the explicit query does. Swap -Qqe for -Qq and every dependency on the machine, # dead ones included, becomes a manifest line install.sh will reinstall on a rebuild. run "$SCRIPT" [[ "$(ADDITIONS)" != *libstray* ]] [[ "$output" == *"undeclared additions (declare these): 2"* ]] [[ "$(section "^orphans" "^flatpak")" == *libstray* ]] run "$SCRIPT" --emit [[ "$output" != *libstray* ]] } @test "--help prints the header and stops before the shell" { # The header IS the contract (report shape, exit status, seams). A hardcoded line range # that runs past the comment block hands the reader `set -euo pipefail` as if it were # documentation. run "$SCRIPT" --help [ "$status" -eq 0 ] [[ "$output" == *"Exit status"* ]] [[ "$output" != *"pipefail"* ]] [[ "$output" != *"LC_ALL"* ]] } @test "--baseline moves packages installed on that date into the installer set" { # Inclusive date semantics: pushing the baseline to bun's install date makes bun installer # furniture, while nirimod-git (the next day) is still a hand install. run env PKGINV_BASELINE=2026-03-04 "$SCRIPT" [[ "$output" == *"baseline=2026-03-04"* ]] s="$(ADDITIONS)" [[ "$s" != *bun* ]] [[ "$s" == *nirimod-git* ]] } @test "--emit prints lines that survive the manifest parser" { run "$SCRIPT" --emit [ "$status" -eq 0 ] # install.sh reads manifests as `sed 's/#.*//'` then the first field, so an emitted name must # be a bare token: a stray path or trailing note would be handed to pacman as a package name. bad="$(printf '%s\n' "$output" | grep -v '^#' | grep -vE '^[A-Za-z0-9@._+-]+$' || true)" [ -z "$bad" ] printf '%s\n' "$output" > "$REPO/packages/emitted.pkgs" names="$(sed 's/#.*//' "$REPO/packages/emitted.pkgs" | awk '{if ($1) print $1}' | sort | tr '\n' ' ')" [[ "$names" == *bun* ]] [[ "$names" == *nirimod-git* ]] [[ "$names" != *ghostty* ]] [[ "$names" != *libfoo* ]] [[ "$names" != *vlc* ]] } @test "an unknown host gets the common manifest only, so host packages look undeclared" { # Mirrors install.sh: wrong-host runs are a no-op, not a hazard. Here that means neovim, # declared only in box.pkgs, counts as an addition when the host is not box. run env PKGINV_HOST=otherbox "$SCRIPT" [[ "$(ADDITIONS)" == *neovim* ]] } @test "no drift exits 0" { printf '%s\n' ghostty libfoo zoxide > "$TEST_TMP/local-explicit" run "$SCRIPT" [ "$status" -eq 0 ] [[ "$output" == *"undeclared additions (declare these): 0"* ]] } @test "missing sync databases fail loudly rather than misclassifying everything as AUR" { run env STUB_SYNC_EMPTY=1 "$SCRIPT" [ "$status" -eq 3 ] [[ "$output" == *"sync"* ]] } @test "flatpak apps are reported separately from pacman packages" { run "$SCRIPT" [[ "$output" == *"flatpak"*com.discordapp.Discord* ]] } @test "a box without flatpak still reports the pacman inventory" { run env STUB_NO_FLATPAK=1 "$SCRIPT" [ "$status" -eq 1 ] [[ "$output" == *"flatpak"* ]] [[ "$(ADDITIONS)" == *bun* ]] } @test "the baseline is found in a rotated log when the install day has aged out of the live one" { # pacman rotates its log; without reading the rotated file the whole base system would be # reported as hand-installed additions on any machine older than the rotation window. mv "$TEST_TMP/pacman.log" "$TEST_TMP/pacman.log.1" printf '[2026-10-01T09:00:00-0700] [ALPM] upgraded zoxide (0.9-1 -> 0.10-1)\n' > "$TEST_TMP/pacman.log" run env PKGINV_LOG="$TEST_TMP/pacman.log $TEST_TMP/pacman.log.1" "$SCRIPT" [[ "$output" == *"baseline=2026-01-01"* ]] [[ "$(ADDITIONS)" != *libfoo* ]] } @test "a log larger than the pipe buffer still yields the baseline" { # A real install day writes thousands of install lines. `sort | head -1` under pipefail dies of # SIGPIPE once that output exceeds the pipe buffer, and no small fixture can show it — the first # run against a real log is where it surfaced. seq, not yes: yes would SIGPIPE in the fixture. seq 1 20000 | awk '{print "[2025-05-05T10:00:00-0700] [ALPM] installed pkgfiller (1-1)"}' > "$TEST_TMP/big.log" cat "$TEST_TMP/big.log" "$TEST_TMP/pacman.log" > "$TEST_TMP/combined.log" run env PKGINV_LOG="$TEST_TMP/combined.log" "$SCRIPT" [ "$status" -eq 1 ] [[ "$output" == *"baseline=2025-05-05"* ]] } @test "usage is a distinct exit status from drift" { run "$SCRIPT" --nope [ "$status" -eq 2 ] }