Files
skeptical-updater/tests/e2e/helpers.bash
T
bryanpedini 296c77e319 feat(prompt): readline line editing with per-session history
When stdin is a terminal the prompt is read with bash's readline,
`IFS= read -e -r -p '> ' REPLY`, so the arrow keys no longer print escape
garbage: Left/Right, Home/End, Ctrl-A/E, Backspace/Delete edit the line,
and Up/Down walk the lines entered in this session (`history -s` for every
line with a non-blank character; empty lines are not recorded). Down past
the newest entry restores the line typed before Up, and Up at the oldest
entry or Down on the new line ring the bell, as readline does by default.
The Nx y/N answer is read the same way and never recorded.

Nothing persists: HISTFILE is unset at startup and the history is never
read or written. Verified on bash 4.3.48 and 5.2.15 that `read -e` and
`history -s` share one history list in a non-interactive shell, that
inherited HISTSIZE/HISTCONTROL/HISTIGNORE have no effect there, and that
Ctrl-C at the prompt restores the terminal. readline draws the prompt on
stderr, as bash does. Piped stdin keeps the plain `read -r` exactly,
including the echo of each line and EOF => exit 2.

New unit tests (tests/unit/lineedit.bats) drive a real pty with script(1):
history recall, the bell at both ends, the stashed line, Left-arrow
editing, the y/N answer kept out of the history, no history file under
HOME/TMPDIR, ^D => exit 2. The E2E suite gets a pty smoke test (Up
recalls 1?), skipped where the image has no script(1).
2026-10-10 14:01:38 +02:00

248 lines
9.3 KiB
Bash

# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/e2e/helpers.bash - shared helpers for the E2E suite (sourced by tests/e2e/*.bats).
# Runs as root inside a distro container prepared by tests/e2e/entrypoint.sh.
# shellcheck shell=bash
load '../vendor/bats-support/load'
load '../vendor/bats-assert/load'
STATE_DIR=/var/lib/skeptical-updater
STATE_FILE=$STATE_DIR/trusted.tsv
FAMILY=${SU_FAMILY:?SU_FAMILY not set (run through tests/e2e/entrypoint.sh)}
case $FAMILY in
apt | pacman) BE=$FAMILY ;;
rpm) if command -v zypper >/dev/null 2>&1; then BE=zypper; else BE=dnf; fi ;;
*) BE=unknown ;;
esac
export STATE_DIR STATE_FILE FAMILY BE
unset SKEPTICAL_UPDATER_STATE_DIR NO_COLOR
# shellcheck source=/dev/null
pretty_name() { (. /etc/os-release && printf '%s' "$PRETTY_NAME"); }
# pkg_ver NAME -> upstream version of the installed package (1.0 / 2.0) or "absent"
pkg_ver() {
local v=''
case $BE in
apt)
v=$(dpkg-query -W -f '${db:Status-Abbrev} ${Version}' "$1" 2>/dev/null) || v=''
# ${db:Status-Abbrev}: 1st char = selection (i, h = hold, ...), 2nd = state (i = installed)
if [[ ${v:1:1} == i ]]; then v=${v##* }; else v=''; fi
;;
pacman)
v=$(pacman -Q "$1" 2>/dev/null) || v=''
v=${v#* }
v=${v%-*}
;;
dnf | zypper) v=$(rpm -q --qf '%{VERSION}' "$1" 2>/dev/null) || v='' ;;
esac
printf '%s' "${v:-absent}"
}
versions() { printf 'a=%s b=%s c=%s' "$(pkg_ver su-dummy-a)" "$(pkg_ver su-dummy-b)" "$(pkg_ver su-dummy-c)"; }
# holds_snapshot: the complete native hold/lock/ignore/exclude state, as text
holds_snapshot() {
case $BE in
apt) apt-mark showhold | sort ;;
pacman)
echo "--- ignorepkg"
cat /etc/pacman.d/ignorepkg
echo "--- pacman-conf IgnorePkg"
pacman-conf IgnorePkg
;;
dnf) cat /etc/dnf/dnf.conf ;;
zypper) if [[ -e /etc/zypp/locks ]]; then cat /etc/zypp/locks; else echo "(no /etc/zypp/locks)"; fi ;;
esac
}
# hold_c: a pre-existing, admin-made hold/lock/IgnorePkg/exclude on su-dummy-c
hold_c() {
case $BE in
apt) apt-mark hold su-dummy-c >/dev/null ;;
pacman) echo 'IgnorePkg = su-dummy-c' >/etc/pacman.d/ignorepkg ;;
dnf) sed -i '/^\[main\]/a excludepkgs=su-dummy-c' /etc/dnf/dnf.conf ;;
zypper) zypper --non-interactive --quiet addlock su-dummy-c >/dev/null ;;
esac
}
e2e_setup_file() {
# remember the pristine configuration once per container
case $BE in
pacman)
# 1.0 packages for e2e_reset (re-running the fixture would rebuild the repo with new
# bytes in the same second, leaving the already synced database stale)
if [[ ! -d /srv/su-old-pacman ]]; then
mkdir -p /srv/su-old-pacman
(
# shellcheck source=fixtures/pacman.sh
. "$BATS_TEST_DIRNAME/fixtures/pacman.sh"
fx_build_tar /srv/su-old-pacman su-dummy-a 1.0 'Skeptical-updater dummy package A' su-dummy-b
fx_build_tar /srv/su-old-pacman su-dummy-b 1.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build_tar /srv/su-old-pacman su-dummy-c 1.0 'Skeptical-updater dummy package C (independent)'
)
fi
;;
dnf) [[ -e /srv/su-orig-dnf.conf ]] || cp -p /etc/dnf/dnf.conf /srv/su-orig-dnf.conf ;;
zypper)
rm -f /etc/zypp/zypp.conf.d/50-su-e2e.conf /etc/zypp/locks-su-e2e
if [[ ! -e /srv/su-orig-locks.state ]]; then
if [[ -e /etc/zypp/locks ]]; then
cp -p /etc/zypp/locks /srv/su-orig-locks
echo present >/srv/su-orig-locks.state
else
echo absent >/srv/su-orig-locks.state
fi
fi
;;
esac
}
# e2e_reset: back to "su-dummy-{a,b,c} 1.0 installed, 2.0 available, no holds, no statefile"
e2e_reset() {
rm -rf -- "$STATE_DIR"
case $BE in
apt)
local -a held=()
mapfile -t held < <(apt-mark showhold)
((${#held[@]} == 0)) || apt-mark unhold "${held[@]}" >/dev/null
dpkg --force-confnew -i /srv/su-old/*.deb </dev/null >/dev/null 2>&1
;;
pacman)
: >/etc/pacman.d/ignorepkg
pacman -U --noconfirm /srv/su-old-pacman/*.pkg.tar.zst >/dev/null 2>&1
pacman -D --asexplicit su-dummy-a su-dummy-c >/dev/null
pacman -D --asdeps su-dummy-b >/dev/null
;;
dnf | zypper)
if [[ $BE == dnf ]]; then
cp -p /srv/su-orig-dnf.conf /etc/dnf/dnf.conf
else
rm -f /etc/zypp/zypp.conf.d/50-su-e2e.conf /etc/zypp/locks-su-e2e
fi
if [[ $BE == dnf ]]; then
:
elif [[ $(cat /srv/su-orig-locks.state) == present ]]; then
cp -p /srv/su-orig-locks /etc/zypp/locks
else
rm -f /etc/zypp/locks
fi
rpm -Uvh --oldpackage --replacepkgs /var/tmp/su-fixture/RPMS/*/su-dummy-[abc]-1.0-1.*.rpm >/dev/null 2>&1
;;
esac
# su-dummy-b is a dependency of su-dummy-a (installed as such), on every backend; su-dummy-a and
# su-dummy-c were installed by the user (dnf records plain rpm installs as reason unknown)
case $BE in
apt) apt-mark auto su-dummy-orphan su-dummy-b >/dev/null ;;
dnf)
dnf -q mark remove su-dummy-b >/dev/null 2>&1 || dnf -q -y mark dependency su-dummy-b >/dev/null 2>&1
dnf -q mark install su-dummy-a su-dummy-c >/dev/null 2>&1 || dnf -q -y mark user su-dummy-a su-dummy-c >/dev/null 2>&1
;;
zypper) grep -qx su-dummy-b /var/lib/zypp/AutoInstalled 2>/dev/null || echo su-dummy-b >>/var/lib/zypp/AutoInstalled ;;
esac
[[ $(versions) == "a=1.0 b=1.0 c=1.0" && $(pkg_ver su-dummy-orphan) == 1.0 ]] || {
echo "e2e_reset: unexpected state: $(versions) orphan=$(pkg_ver su-dummy-orphan)" >&2
return 1
}
}
# the native upgrade command line printed by --dry-run (without exclusions)
native_upgrade_regex() {
case $BE in
apt) printf '%s' '^\+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade$' ;;
pacman) printf '%s' '^\+ pacman -Su --noconfirm$' ;; # after the reviewed databases were installed
dnf) printf '%s' '^\+ (dnf|yum) -y --setopt=skip_if_unavailable=0 --setopt=\\\*\.metadata_expire=-1 upgrade$' ;;
zypper) printf '%s' '^\+ zypper --non-interactive --no-refresh (update|dist-upgrade)$' ;;
esac
}
# rpm_publish NAME VERSION: build NAME-VERSION-1 (arch-specific for su-dummy-c, like the fixture)
# with the fixture's rpmbuild tree and add it to the su-dummy repository
rpm_publish() {
local name=$1 ver=$2 top=/var/tmp/su-fixture repo=/var/lib/su-dummy-repo spec
spec=$top/SPECS/$name-$ver.spec
{
echo "Name: $name"
echo "Version: $ver"
echo "Release: 1"
echo "Summary: skeptical-updater dummy $name (v$ver)"
echo "License: MIT"
[[ $name == su-dummy-c ]] || echo "BuildArch: noarch"
echo "%global debug_package %{nil}"
echo "%description"
echo "Dummy package $name version $ver."
echo "%install"
echo "mkdir -p %{buildroot}/usr/share/su-dummy"
echo "echo '$name $ver' > %{buildroot}/usr/share/su-dummy/$name"
echo "%files"
echo "/usr/share/su-dummy/$name"
} >"$spec"
rpmbuild -bb --quiet --define "_topdir $top" "$spec" >/dev/null 2>&1
cp "$top"/RPMS/*/"$name-$ver"-1.*.rpm "$repo"/
createrepo_c --quiet --update "$repo" >/dev/null
}
rpm_unpublish() {
rm -f /var/lib/su-dummy-repo/"$1-$2"-1.*.rpm
createrepo_c --quiet --update /var/lib/su-dummy-repo >/dev/null
}
# pacman_sync_snapshot: the real sync database directory, with full timestamps
pacman_sync_snapshot() { ls -la --time-style=full-iso /var/lib/pacman/sync 2>&1; }
# pacman_publish NAME VERSION / pacman_unpublish: add a package to the fixture's [su-dummy] repo,
# and rebuild that repo with only the fixture's 2.0 packages again
pacman_publish() {
(
# shellcheck source=fixtures/pacman.sh
. "$BATS_TEST_DIRNAME/fixtures/pacman.sh"
fx_build_tar /srv/su-repo "$1" "$2" "Skeptical-updater dummy package $1 $2"
)
(cd /srv/su-repo && repo-add --quiet su-dummy.db.tar.gz "./$1-$2-1-any.pkg.tar.zst")
}
pacman_unpublish() {
(cd /srv/su-repo && rm -f su-dummy.db* su-dummy.files* &&
for f in ./*.pkg.tar.zst; do case $f in ./su-dummy-[abc]-2.0-1-any.pkg.tar.zst) ;; *) rm -f "$f" ;; esac; done &&
repo-add --quiet su-dummy.db.tar.gz ./su-dummy-a-2.0-1-any.pkg.tar.zst ./su-dummy-b-2.0-1-any.pkg.tar.zst ./su-dummy-c-2.0-1-any.pkg.tar.zst)
}
# pty_session INPUT ARGS...: run skeptical-updater ARGS on a pseudo-terminal (script(1) of
# util-linux) with INPUT typed in one go (readline consumes it as it reads: no timing involved);
# sets status, output and lines like `run` (without the pty's CRs; NO_COLOR as stdout is a terminal)
# and PTY_RAW, the raw transcript. Skips the test when the image has no script(1).
# shellcheck disable=SC2034 # status/lines are read by bats-assert
pty_session() {
local input=$1 cmd
shift
command -v script >/dev/null 2>&1 || skip "script(1) (util-linux) not available in this image: no pty to drive"
PTY_RAW=$BATS_TEST_TMPDIR/pty.raw
printf -v cmd '%q ' env TERM=dumb NO_COLOR=1 skeptical-updater "$@"
status=0
printf '%s' "$input" | timeout 120 script -qfec "$cmd" /dev/null >"$PTY_RAW" 2>&1 || status=$?
output=$(tr -d '\r' <"$PTY_RAW")
mapfile -t lines <<<"$output"
}
# session_start ARGS...: run skeptical-updater ARGS in the background with a FIFO as stdin and wait
# for its prompt; sets SESSION_PID and SESSION_FD (write the input to it; fd 3 belongs to bats)
session_start() {
mkfifo "$BATS_TEST_TMPDIR/in"
skeptical-updater "$@" <"$BATS_TEST_TMPDIR/in" >"$BATS_TEST_TMPDIR/out" 2>&1 &
SESSION_PID=$!
exec {SESSION_FD}>"$BATS_TEST_TMPDIR/in"
for _ in $(seq 1 600); do
grep -q '^<enter> upgrade' "$BATS_TEST_TMPDIR/out" && return 0
sleep 0.1
done
return 1
}
# session_finish INPUT: send INPUT, close stdin, wait; sets SESSION_RC (read by the .bats files)
# shellcheck disable=SC2034
session_finish() {
printf '%s\n' "$1" >&"$SESSION_FD"
exec {SESSION_FD}>&-
SESSION_RC=0
wait "$SESSION_PID" || SESSION_RC=$?
}