Add shellcheck lint and bats tests
make lint runs shellcheck on every tracked bash script, make test runs the bats suite in tests/, and make check runs both. The tests cover the update orchestrator (hook isolation, failure reporting, pull and free-space handling) and utils.sh (migrations, package install and removal, services, initramfs build check), using stub commands so they never touch the real system. Fixes from the first lint run: 20-aur.sh stops if a cd fails rather than building or deleting in the wrong directory, and 80-gnome-extensions.sh refuses to rm -rf with an empty path. The rest are false positives, silenced in .shellcheckrc or per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Shared setup for the bats tests. Each test gets its own HOME and a bin dir at
|
||||
# the front of PATH where it can put fake versions of pacman, yay, sudo, etc.
|
||||
|
||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
|
||||
common_setup() {
|
||||
export HOME="$BATS_TEST_TMPDIR/home"
|
||||
export STUB_BIN="$BATS_TEST_TMPDIR/bin"
|
||||
export STUB_LOG="$BATS_TEST_TMPDIR/stub.log"
|
||||
mkdir -p "$HOME" "$STUB_BIN"
|
||||
: > "$STUB_LOG"
|
||||
export PATH="$STUB_BIN:$PATH"
|
||||
unset COREDESKTOP
|
||||
}
|
||||
|
||||
# A test file that needs more setup defines its own setup that calls this first
|
||||
setup() {
|
||||
common_setup
|
||||
}
|
||||
|
||||
# stub NAME [BODY]: put a fake NAME on PATH that appends "NAME args..." to
|
||||
# $STUB_LOG and then runs BODY, a bash snippet that sees the args as "$@"
|
||||
stub() {
|
||||
local name="$1" body="${2:-}"
|
||||
cat > "$STUB_BIN/$name" << EOF
|
||||
#!/bin/bash
|
||||
echo "$name \$*" >> "\$STUB_LOG"
|
||||
$body
|
||||
EOF
|
||||
chmod +x "$STUB_BIN/$name"
|
||||
}
|
||||
|
||||
# called NAME ARGS...: true if the stub log has exactly the line "NAME ARGS..."
|
||||
called() {
|
||||
grep -qxF "$*" "$STUB_LOG"
|
||||
}
|
||||
|
||||
# not_called NAME: true if the stub NAME never ran
|
||||
not_called() {
|
||||
! grep -q "^$1 " "$STUB_LOG"
|
||||
}
|
||||
|
||||
# uncalled NAME ARGS...: the opposite of called. (A bare "! called ..." would
|
||||
# not fail the test: bats, like set -e, ignores the status of a ! command.)
|
||||
uncalled() {
|
||||
! called "$@"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
load test_helper
|
||||
|
||||
# Each test runs the real update script from a fake CoreDesktop checkout in the
|
||||
# test's HOME, with fake hooks in updates.d/ that log to $RAN when they run.
|
||||
|
||||
setup() {
|
||||
# A checkout with nothing but the update script and the files it installs
|
||||
common_setup
|
||||
CD="$HOME/.local/share/coredesktop"
|
||||
mkdir -p "$CD/updates.d" "$HOME/.local/bin"
|
||||
cp "$REPO/update" "$CD/update"
|
||||
for script in refresh sibling version configure audit; do
|
||||
echo '#!/bin/bash' > "$CD/$script"
|
||||
done
|
||||
|
||||
export RAN="$BATS_TEST_TMPDIR/ran"
|
||||
unset UPDATE_REEXECED UPDATE_PULL_FAILED UPDATE_FORCE
|
||||
|
||||
stub clear
|
||||
stub git 'exit "${GIT_STATUS:-0}"'
|
||||
stub sudo 'exit "${SUDO_STATUS:-0}"'
|
||||
stub df 'printf "Avail\n%s\n" "${DF_AVAIL:-104857600}"'
|
||||
# Let go of bats' output pipes so the sudo keepalive's leftover sleep
|
||||
# doesn't keep the test waiting
|
||||
stub sleep 'exec > /dev/null 2>&1 3>&-; exec /usr/bin/sleep "$@"'
|
||||
}
|
||||
|
||||
# hook NAME BODY: add updates.d/NAME.sh with BODY as its run_update
|
||||
hook() {
|
||||
printf '#!/bin/bash\nrun_update() {\n%s\n}\n' "$2" > "$CD/updates.d/$1.sh"
|
||||
}
|
||||
|
||||
ran() {
|
||||
[ "$(cat "$RAN" 2> /dev/null)" = "$(printf '%s\n' "$@")" ]
|
||||
}
|
||||
|
||||
@test "update pulls, installs its scripts, and runs every hook in order" {
|
||||
hook 20-b 'echo b >> "$RAN"'
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Done."* ]]
|
||||
called git -C "$CD" pull
|
||||
ran a b
|
||||
for script in update refresh sibling version configure audit; do
|
||||
[ -x "$HOME/.local/bin/$script" ]
|
||||
done
|
||||
}
|
||||
|
||||
@test "update pulls only once, before re-running its new copy" {
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(grep -c '^git ' "$STUB_LOG")" -eq 1 ]
|
||||
ran a
|
||||
}
|
||||
|
||||
@test "a failing hook doesn't stop the hooks after it" {
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
hook 20-b 'echo b >> "$RAN"; return 1'
|
||||
hook 30-c 'echo c >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"Done, with errors in: 20-b.sh"* ]]
|
||||
ran a b c
|
||||
}
|
||||
|
||||
@test "a hook that exits doesn't end the update" {
|
||||
hook 10-a 'echo a >> "$RAN"; exit 0'
|
||||
hook 20-b 'echo b >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 0 ]
|
||||
ran a b
|
||||
}
|
||||
|
||||
@test "a hook using set -e fails at its first failing command" {
|
||||
hook 10-a 'set -e; echo a >> "$RAN"; false; echo after >> "$RAN"'
|
||||
hook 20-b 'echo b >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"errors in: 10-a.sh"* ]]
|
||||
ran a b
|
||||
}
|
||||
|
||||
@test "a hook's is_installed and functions don't leak into the next hook" {
|
||||
printf '%s\n' '#!/bin/bash' 'is_installed() { false; }' \
|
||||
'run_update() { echo a >> "$RAN"; }' > "$CD/updates.d/10-a.sh"
|
||||
# 10-a's is_installed must not stop 20-b, and its run_update must not run
|
||||
# again for 30-c, which has none
|
||||
printf '%s\n' '#!/bin/bash' 'run_update() { echo b >> "$RAN"; }' \
|
||||
> "$CD/updates.d/20-b.sh"
|
||||
printf '%s\n' '#!/bin/bash' 'true' > "$CD/updates.d/30-c.sh"
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 0 ]
|
||||
ran b
|
||||
}
|
||||
|
||||
@test "update runs the hooks on disk when git pull fails, and reports it" {
|
||||
export GIT_STATUS=1
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"could not pull"* ]]
|
||||
[[ "$output" == *"errors in: git pull"* ]]
|
||||
ran a
|
||||
}
|
||||
|
||||
@test "update stops before any hook when / is low on space" {
|
||||
export DF_AVAIL=1048576
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"needs at least 10 GiB"* ]]
|
||||
[ ! -e "$RAN" ]
|
||||
}
|
||||
|
||||
@test "UPDATE_FORCE skips the free space check" {
|
||||
export DF_AVAIL=1048576 UPDATE_FORCE=1
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 0 ]
|
||||
ran a
|
||||
}
|
||||
|
||||
@test "update stops when sudo fails" {
|
||||
export SUDO_STATUS=1
|
||||
hook 10-a 'echo a >> "$RAN"'
|
||||
|
||||
run "$CD/update"
|
||||
[ "$status" -eq 1 ]
|
||||
[ ! -e "$RAN" ]
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
load test_helper
|
||||
|
||||
# --- run_migrations ---------------------------------------------------------
|
||||
|
||||
# migration NAME [EXIT]: add migrations/NAME.sh that logs its name and exits EXIT
|
||||
migration() {
|
||||
mkdir -p "$COREDESKTOP/migrations"
|
||||
printf '#!/bin/bash\necho %s >> "%s"\nexit %s\n' "$1" "$RAN" "${2:-0}" \
|
||||
> "$COREDESKTOP/migrations/$1.sh"
|
||||
}
|
||||
|
||||
setup_migrations() {
|
||||
export COREDESKTOP="$BATS_TEST_TMPDIR/coredesktop"
|
||||
RAN="$BATS_TEST_TMPDIR/ran"
|
||||
STATE="$HOME/.local/state/coredesktop/migrations"
|
||||
source "$REPO/utils.sh"
|
||||
}
|
||||
|
||||
@test "run_migrations runs each migration once, oldest first" {
|
||||
setup_migrations
|
||||
migration 1792592710
|
||||
migration 1792592700
|
||||
migration 1792592720
|
||||
|
||||
run run_migrations
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(cat "$RAN")" = "$(printf '1792592700\n1792592710\n1792592720')" ]
|
||||
[ -f "$STATE/1792592700.sh" ]
|
||||
[ -f "$STATE/1792592720.sh" ]
|
||||
|
||||
run run_migrations
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(wc -l < "$RAN")" -eq 3 ]
|
||||
}
|
||||
|
||||
@test "run_migrations stops at a failure and retries it next time" {
|
||||
setup_migrations
|
||||
migration 1792592700
|
||||
migration 1792592710 1
|
||||
migration 1792592720
|
||||
|
||||
run run_migrations
|
||||
[ "$status" -eq 1 ]
|
||||
[ "$(cat "$RAN")" = "$(printf '1792592700\n1792592710')" ]
|
||||
[ -f "$STATE/1792592700.sh" ]
|
||||
[ ! -f "$STATE/1792592710.sh" ]
|
||||
[ ! -f "$STATE/1792592720.sh" ]
|
||||
|
||||
migration 1792592710
|
||||
run run_migrations
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(tail -n 2 "$RAN")" = "$(printf '1792592710\n1792592720')" ]
|
||||
}
|
||||
|
||||
@test "run_migrations does nothing without a migrations dir" {
|
||||
setup_migrations
|
||||
run run_migrations
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -e "$RAN" ]
|
||||
}
|
||||
|
||||
@test "migrations are named by timestamp so they sort in the order written" {
|
||||
for file in "$REPO"/migrations/*; do
|
||||
[[ "$(basename "$file")" =~ ^[0-9]{10}\.sh$ ]] || {
|
||||
echo "bad migration name: $file"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
# --- install_packages / remove_packages -------------------------------------
|
||||
|
||||
# A fake pacman that knows about the packages in $INSTALLED, of which those in
|
||||
# $AS_DEPS were installed as dependencies
|
||||
stub_pacman() {
|
||||
stub pacman '
|
||||
case "$1" in
|
||||
-Qi) [[ " $INSTALLED " == *" $2 "* ]] ;;
|
||||
-Qg) exit 1 ;;
|
||||
-Qdq) shift; for p; do [[ " $AS_DEPS " == *" $p "* ]] && echo "$p"; done; exit 0 ;;
|
||||
esac'
|
||||
stub sudo
|
||||
stub yay
|
||||
}
|
||||
|
||||
@test "install_packages installs only the missing packages" {
|
||||
export INSTALLED="git vim" AS_DEPS=""
|
||||
stub_pacman
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run install_packages git tmux vim neovim
|
||||
[ "$status" -eq 0 ]
|
||||
called yay -S --noconfirm --answerdiff=None --answeredit=None --answerclean=None tmux neovim
|
||||
not_called sudo
|
||||
}
|
||||
|
||||
@test "install_packages does nothing when everything is installed" {
|
||||
export INSTALLED="git vim" AS_DEPS=""
|
||||
stub_pacman
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run install_packages git vim
|
||||
[ "$status" -eq 0 ]
|
||||
not_called yay
|
||||
}
|
||||
|
||||
@test "install_packages marks packages installed as dependencies explicit" {
|
||||
export INSTALLED="git python" AS_DEPS="python"
|
||||
stub_pacman
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run install_packages git python
|
||||
[ "$status" -eq 0 ]
|
||||
called sudo pacman -D --asexplicit python
|
||||
not_called yay
|
||||
}
|
||||
|
||||
@test "remove_packages removes only exact matches, not providers" {
|
||||
# herdr-bin provides herdr, so pacman -Qq herdr prints herdr-bin
|
||||
stub pacman '
|
||||
[ "$1" = -Qq ] || exit 1
|
||||
case "$2" in
|
||||
herdr) echo herdr-bin ;;
|
||||
vim) echo vim ;;
|
||||
*) exit 1 ;;
|
||||
esac'
|
||||
stub sudo
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run remove_packages herdr vim missing
|
||||
[ "$status" -eq 0 ]
|
||||
called sudo pacman -Rns --noconfirm vim
|
||||
}
|
||||
|
||||
@test "remove_packages does nothing when nothing is installed" {
|
||||
stub pacman 'exit 1'
|
||||
stub sudo
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run remove_packages vim
|
||||
[ "$status" -eq 0 ]
|
||||
not_called sudo
|
||||
}
|
||||
|
||||
# --- enable_services --------------------------------------------------------
|
||||
|
||||
@test "enable_services enables disabled services and skips the rest" {
|
||||
stub systemctl '[ "$1" = is-enabled ] && [ "$2" = sshd ]'
|
||||
stub sudo
|
||||
mkdir -p "$HOME/.config/coredesktop"
|
||||
echo "system cups" > "$HOME/.config/coredesktop/services-off"
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run enable_services --now sshd cups bluetooth
|
||||
[ "$status" -eq 0 ]
|
||||
called sudo systemctl daemon-reload
|
||||
called sudo systemctl enable --now bluetooth
|
||||
uncalled sudo systemctl enable --now sshd
|
||||
uncalled sudo systemctl enable --now cups
|
||||
[[ "$output" == *"cups is turned off"* ]]
|
||||
}
|
||||
|
||||
@test "enable_services without --now only enables" {
|
||||
stub systemctl 'exit 1'
|
||||
stub sudo
|
||||
source "$REPO/utils.sh"
|
||||
|
||||
run enable_services bluetooth
|
||||
[ "$status" -eq 0 ]
|
||||
called sudo systemctl enable bluetooth
|
||||
uncalled sudo systemctl daemon-reload
|
||||
}
|
||||
|
||||
# --- initramfs_build_failed -------------------------------------------------
|
||||
|
||||
pacman_log() {
|
||||
mkdir -p "$HOME/.local/state/coredesktop"
|
||||
printf '%s\n' "$@" > "$HOME/.local/state/coredesktop/pacman-update.log"
|
||||
source "$REPO/utils.sh"
|
||||
}
|
||||
|
||||
@test "initramfs_build_failed is false without a log" {
|
||||
source "$REPO/utils.sh"
|
||||
run initramfs_build_failed
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "initramfs_build_failed is false when no initramfs was built" {
|
||||
pacman_log "upgrading vim..."
|
||||
run initramfs_build_failed
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "initramfs_build_failed is false when every image built" {
|
||||
pacman_log "(3/4) Updating linux initcpios..." \
|
||||
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'" \
|
||||
"==> Image generation successful" \
|
||||
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'fallback'" \
|
||||
"==> Initcpio image generation successful"
|
||||
run initramfs_build_failed
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "initramfs_build_failed is true when mkinitcpio reports an error" {
|
||||
pacman_log "(3/4) Updating linux initcpios..." \
|
||||
"==> ERROR: module not found" \
|
||||
"==> Image generation successful"
|
||||
run initramfs_build_failed
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "initramfs_build_failed is true when an image didn't build" {
|
||||
pacman_log "(3/4) Updating linux initcpios..." \
|
||||
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'" \
|
||||
"==> Image generation successful" \
|
||||
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'fallback'"
|
||||
run initramfs_build_failed
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
Reference in New Issue
Block a user