install.sh installs desired − installed from packages/*.pkgs, which guarantees a fresh box reaches the declared state — and nothing anywhere checked that the declared state still describes the machines. Measured against the Calamares install day, blankpad carries 41 hand-installed packages and packages/blankpad.pkgs declared one line. pkg-inventory.sh walks the other direction: installed − declared, with the pacman log as the baseline so installer furniture is never reported as a hand install. It splits additions by whether a sync db can serve them (Chaotic-AUR counts, so only true AUR routes to aur.pkgs), excludes orphans from --emit, reports flatpak apps without pretending install.sh installs them, and exits 3 rather than guessing when the sync databases are unreadable. 16 black-box tests over fixtures and a stubbed pacman. Two of them exist because the first implementation was wrong in ways a small fixture cannot show: sort|head under pipefail dies of SIGPIPE on a real 1000-line install-day log (awk-exit closes the pipe exactly as head does), and tr without a trailing echo glues the next report label onto the last folded name. Docs: omp-docs/tools/packages.md now owns the package tier — install.sh was never documented. Refs #51
224 lines
9.7 KiB
Bash
Executable File
224 lines
9.7 KiB
Bash
Executable File
#!/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-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: orphan.
|
|
printf '%s\n' base bun filesystem ghostty libfoo 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 > "$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 reported as ignored and never emitted" {
|
|
run "$SCRIPT"
|
|
[[ "$output" == *orphans*python-hatchling* ]]
|
|
run "$SCRIPT" --emit
|
|
[[ "$output" != *python-hatchling* ]]
|
|
}
|
|
|
|
@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 ]
|
|
}
|