N?? and `show PKG` now print the installed record in the package
manager's own key/value layout, after one line explaining the format:
# lines starting with > come from the upstream candidate: a differing
value, or a field the installed record lacks
After each field whose candidate value differs comes the candidate's
line prefixed with "> "; fields only the candidate has are appended the
same way; installed-only fields stay; identical fields print once.
Multi-line values (pacman Optional Deps, dnf Description, RFC-822
continuation lines) are compared as blocks and a differing candidate
block gets "> " on every line. A package that is not installed shows the
candidate alone ("# not installed: this is the upstream candidate").
Merged where the backend gives comparable records for both sides:
- pacman: -Qi vs -Si REPO/PKG, REPO = the first repository -Sl lists
- apt: dpkg-query -s (Status, Conffiles, the long description; richer
than apt-cache show PKG=<installed>, which gives the archive record
when that version is still listed) vs apt-cache show --no-all-versions;
Description-xx pairs with Description and a one-line candidate
Description equal to the installed first line counts as identical
(the archives ship the long text in Translation files)
- dnf: info --installed vs info --upgrades (--available lists other
architectures first and nothing for an up-to-date package); not
installed: --available; dnf5's differently padded keys are re-padded
zypper keeps `zypper info --requires`: rpm -qi and zypper info share
almost no keys and zypper cannot print the installed record itself.
No candidate, or not exactly one record per side, keeps the old output.
Formats verified in containers and recorded in discovery/*.md. Unit
tests replay recorded pacman, apt, dnf4 and dnf5 records; the E2E suite
checks 2?? on su-dummy-b on all 12 distros.
375 lines
14 KiB
Bash
375 lines
14 KiB
Bash
#!/usr/bin/env bats
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
# Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
# The interactive grammar (stdin is the command channel; no TTY): apt backend, fixture rows
|
|
# [1] su-dummy-a [2] su-dummy-b [3] su-dummy-c (su-dummy-a depends on su-dummy-b).
|
|
|
|
load test_helper
|
|
|
|
setup() { common_setup; }
|
|
|
|
UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade'
|
|
|
|
@test "the session shows the list and the prompt help; q quits with exit 0, nothing upgraded" {
|
|
run skup --no-refresh <<<'q'
|
|
assert_success
|
|
assert_line --index 0 'Backend: apt (Debian GNU/Linux 12 (bookworm))'
|
|
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
|
|
assert_line ' [1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line '<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever'
|
|
assert_line '> q'
|
|
assert_line 'Quit: nothing was upgraded.'
|
|
log_lacks '^apt-get -y'
|
|
log_lacks '^apt-mark (un)?hold'
|
|
}
|
|
|
|
@test "an empty line proceeds: the native upgrade runs, exit 0" {
|
|
run skup <<<''
|
|
assert_success
|
|
assert_line 'Excluded from this run: none'
|
|
assert_line 'Upgrade finished.'
|
|
log_has 'apt-get update -qq -o APT::Update::Error-Mode=any'
|
|
log_has "$UPGRADE"
|
|
}
|
|
|
|
@test "a whitespace-only line also proceeds" {
|
|
run skup --no-refresh <<<' '
|
|
assert_success
|
|
log_has "$UPGRADE"
|
|
}
|
|
|
|
@test "the exit status of the native upgrade is passed through" {
|
|
FAKE_APT_UPGRADE_RC=100 run skup --no-refresh <<<''
|
|
assert_failure 100
|
|
assert_output --partial 'the upgrade failed: apt exited with status 100'
|
|
}
|
|
|
|
@test "EOF aborts with exit 2 without upgrading" {
|
|
run skup --no-refresh <<<'1?'
|
|
assert_failure 2
|
|
assert_output --partial 'end of input: aborted, nothing was upgraded'
|
|
log_lacks '^apt-get -y'
|
|
}
|
|
|
|
@test "EOF straight away (empty stdin) also aborts with exit 2" {
|
|
run skup --no-refresh </dev/null
|
|
assert_failure 2
|
|
log_lacks '^apt-get -y'
|
|
}
|
|
|
|
@test "a last line without a newline is still executed before EOF" {
|
|
run skup --no-refresh < <(printf '2-')
|
|
assert_failure 2
|
|
assert_line 'excluded from this run: su-dummy-b'
|
|
}
|
|
|
|
@test "N? prints the description, the install reason and the installed packages that depend on it" {
|
|
run skup --no-refresh <<<$'1?\n2?\nq'
|
|
assert_success
|
|
assert_output --partial "$(printf '%s\n' '> 1?' \
|
|
'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests' \
|
|
'reason: explicitly installed' \
|
|
'required by: nothing' \
|
|
'> 2?' \
|
|
'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests' \
|
|
'reason: installed as a dependency' \
|
|
'required by: su-dummy-a')"
|
|
refute_output --partial 'required by (installed)'
|
|
}
|
|
|
|
@test "N? on a new dependency (old version -) says 'not installed' without asking the package manager" {
|
|
FAKE_APT_SIM=real run skup --no-refresh <<<$'9?\nq'
|
|
assert_success
|
|
assert_line --regexp '^ \[9\] su-dummy-newdep +- +-> +1\.0 +su-fixture:su-repo$'
|
|
assert_output --partial "$(printf '%s\n' '> 9?' \
|
|
'su-dummy-newdep: (no description available: unknown package?)' \
|
|
'reason: not installed' \
|
|
'required by: nothing')"
|
|
log_lacks '^apt-mark show'
|
|
log_lacks '^dpkg-query -W -f \$\{db:Status-Abbrev\} '
|
|
}
|
|
|
|
@test "N?? prints the installed record merged with the candidate" {
|
|
run skup --no-refresh <<<$'1??\nq'
|
|
assert_success
|
|
assert_line '# lines starting with > come from the upstream candidate: a differing value, or a field the installed record lacks'
|
|
assert_line 'Package: su-dummy-a'
|
|
assert_line 'Version: 1.0'
|
|
assert_line '> Version: 2.0'
|
|
assert_line '> Depends: su-dummy-b (>= 2.0)'
|
|
log_has 'dpkg-query -s -- su-dummy-a'
|
|
log_has 'apt-cache show --no-all-versions su-dummy-a'
|
|
}
|
|
|
|
@test "N?? on a new dependency (old version -) never looks for an installed record" {
|
|
FAKE_APT_SIM=real run skup --no-refresh <<<$'9??\nq'
|
|
assert_success
|
|
log_lacks '^dpkg-query -s'
|
|
log_has 'apt-cache show --no-all-versions su-dummy-newdep'
|
|
}
|
|
|
|
@test "N- toggles the exclusion and the '-' marker" {
|
|
run skup --no-refresh <<<$'2-\nl\n2-\nl\n2-\n'
|
|
assert_success
|
|
assert_line 'excluded from this run: su-dummy-b'
|
|
assert_line 're-included: su-dummy-b'
|
|
assert_line '-[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line 'Excluded from this run: su-dummy-b'
|
|
log_has 'apt-mark hold su-dummy-b'
|
|
log_has 'apt-mark unhold su-dummy-b'
|
|
}
|
|
|
|
@test "Nx asks y/N on the same stdin; y purges, the row leaves, numbers stay stable" {
|
|
run skup --no-refresh <<<$'3x\ny\nl\n3?\nq'
|
|
assert_success
|
|
assert_line 'Purge su-dummy-c now? [y/N] y'
|
|
assert_line 'purged: su-dummy-c'
|
|
log_has 'apt-get -y purge su-dummy-c'
|
|
log_lacks 'autoremove --purge'
|
|
assert_line '2 upgradable, 0 trusted (hidden, use --all to show), 2 to review:'
|
|
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
[[ $(grep -c '^ \[3\] su-dummy-c' <<<"$output") -eq 1 ]]
|
|
assert_output --partial 'no package [3] in the list (l to redraw)'
|
|
}
|
|
|
|
@test "Nx answered with N, n, empty or anything else does not purge" {
|
|
for answer in N n '' maybe; do
|
|
: >"$FAKE_LOG"
|
|
run skup --no-refresh <<<"3x"$'\n'"$answer"$'\nq'
|
|
assert_success
|
|
assert_line 'not purged: su-dummy-c'
|
|
log_lacks '^apt-get -y'
|
|
done
|
|
}
|
|
|
|
@test "Nx: a failing purge keeps the row" {
|
|
FAKE_APT_PURGE_RC=100 run skup --no-refresh <<<$'3x\nyes\nl\nq'
|
|
assert_success
|
|
assert_output --partial 'purge of su-dummy-c failed (apt exited 100); the row stays in the list'
|
|
assert_line ' [3] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
|
|
}
|
|
|
|
@test "Nx: EOF at the confirmation aborts with exit 2 and purges nothing" {
|
|
run skup --no-refresh <<<'3x'
|
|
assert_failure 2
|
|
log_lacks '^apt-get -y'
|
|
}
|
|
|
|
@test "Nx on a package other installed packages depend on is refused, like pacman -Rns; the row stays" {
|
|
run skup --no-refresh <<<$'2x\nl\nq'
|
|
assert_success
|
|
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
|
|
refute_output --partial '[y/N]'
|
|
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
|
|
[[ $(grep -c '^ \[2\] su-dummy-b' <<<"$output") -eq 2 ]]
|
|
log_lacks '^apt-get -y'
|
|
}
|
|
|
|
@test "Nx removes the target with the dependencies only it needed, listed in the prompt" {
|
|
FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x\ny\nl\nq'
|
|
assert_success
|
|
assert_line 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N] y'
|
|
assert_line 'purged: su-dummy-a su-dummy-b'
|
|
assert_line '1 upgradable, 0 trusted (hidden, use --all to show), 1 to review:'
|
|
assert_line ' [3] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
|
|
}
|
|
|
|
@test "Nx: rows the purge took with it unannounced leave the list too (re-listed afterwards)" {
|
|
FAKE_APT_PURGE_ALSO=su-dummy-a run skup --no-refresh <<<$'3x\ny\nl\nq'
|
|
assert_success
|
|
assert_line ' (su-dummy-a is no longer upgradable, removed from the list)'
|
|
assert_line '1 upgradable, 0 trusted (hidden, use --all to show), 1 to review:'
|
|
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
}
|
|
|
|
@test "Nx on a new dependency (old version -, not installed) is refused" {
|
|
FAKE_APT_SIM=real run skup --no-refresh <<<$'9x\nq'
|
|
assert_success
|
|
assert_line 'su-dummy-newdep is not installed (the upgrade would install it as a new dependency): nothing to purge; use 9- to exclude it from this run.'
|
|
refute_output --partial '[y/N]'
|
|
log_lacks 'purge'
|
|
}
|
|
|
|
@test "Nx refused by the package manager asks nothing" {
|
|
FAKE_APT_SIM_PURGE_RC=100 run skup --no-refresh <<<$'3x\nq'
|
|
assert_success
|
|
assert_output --partial 'E: Unable to correct problems, you have held broken packages.'
|
|
assert_output --partial 'cannot purge su-dummy-c: apt refuses (see above); nothing was removed'
|
|
refute_output --partial '[y/N]'
|
|
log_lacks '^apt-get -y'
|
|
}
|
|
|
|
@test "purging the last packages ends the session cleanly (exit 0)" {
|
|
export FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b
|
|
run skup --no-refresh <<<$'3x\ny\n1x\ny'
|
|
assert_success
|
|
assert_line 'purged: su-dummy-c'
|
|
assert_line 'purged: su-dummy-a su-dummy-b'
|
|
assert_line 'Nothing left to upgrade.'
|
|
log_lacks "^apt-get -y -o"
|
|
}
|
|
|
|
@test "purging every listed row keeps the session while trusted (hidden) rows remain" {
|
|
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
|
|
FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x\ny\nl\nq'
|
|
assert_success
|
|
assert_line '1 upgradable, 1 trusted (hidden, use --all to show), 0 to review:'
|
|
assert_line 'Quit: nothing was upgraded.'
|
|
}
|
|
|
|
@test "N+ trusts with the closure: rows leave, numbers stay, the hidden count grows" {
|
|
run skup --no-refresh <<<$'1+\nl\nq'
|
|
assert_success
|
|
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
|
|
assert_line '3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:'
|
|
assert_line ' [3] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
|
|
# [1] and [2] only appear in the first listing
|
|
[[ $(grep -c '^ \[1\] su-dummy-a' <<<"$output") -eq 1 ]]
|
|
[[ $(grep -c '^ \[2\] su-dummy-b' <<<"$output") -eq 1 ]]
|
|
run cut -f1-3 "$STATE_FILE"
|
|
assert_line "apt${T}su-dummy-a${T}user"
|
|
assert_line "apt${T}su-dummy-b${T}dep:su-dummy-a"
|
|
}
|
|
|
|
@test "trusting a package re-includes the excluded rows that this trust hides" {
|
|
run skup --no-refresh <<<$'2-\n1+\n\n'
|
|
assert_success
|
|
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
|
|
assert_line ' (su-dummy-b is trusted now: no longer excluded from this run)'
|
|
assert_line 'Excluded from this run: none'
|
|
log_lacks '^apt-mark hold'
|
|
}
|
|
|
|
@test "with --all a trusted row stays visible, so its exclusion stays too" {
|
|
run skup --no-refresh --all <<<$'2-\n1+\n\n'
|
|
assert_success
|
|
refute_output --partial 'no longer excluded'
|
|
assert_line 'Excluded from this run: su-dummy-b'
|
|
}
|
|
|
|
@test "trusted packages are hidden with a count; numbering covers the listed rows only" {
|
|
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
|
|
run skup --no-refresh <<<$'1?\nq'
|
|
assert_success
|
|
assert_line '3 upgradable, 1 trusted (hidden, use --all to show), 2 to review:'
|
|
assert_line ' [1] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line ' [2] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests'
|
|
}
|
|
|
|
@test "--all lists trusted packages too, marked with '='" {
|
|
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
|
|
run skup --no-refresh --all <<<$'2+\nl\nq'
|
|
assert_success
|
|
assert_line '3 upgradable, 1 trusted (marked with =), 2 to review:'
|
|
assert_line '=[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line 'trusted su-dummy-b'
|
|
assert_line '3 upgradable, 2 trusted (marked with =), 1 to review:'
|
|
assert_line '=[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
}
|
|
|
|
@test "trusted packages are upgraded silently (never excluded)" {
|
|
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
|
|
run skup --no-refresh <<<''
|
|
assert_success
|
|
assert_line 'Excluded from this run: none'
|
|
log_lacks '^apt-mark hold'
|
|
log_has "$UPGRADE"
|
|
}
|
|
|
|
@test "everything trusted: 0 to review, the prompt is still offered" {
|
|
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
|
|
"apt${T}su-dummy-b${T}user${T}2026-01-01T00:00:00Z" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
|
|
run skup --no-refresh <<<''
|
|
assert_success
|
|
assert_line '3 upgradable, 3 trusted (hidden, use --all to show), 0 to review:'
|
|
log_has "$UPGRADE"
|
|
}
|
|
|
|
@test "*+ trusts every listed package" {
|
|
run skup --no-refresh <<<$'*+\nl\nq'
|
|
assert_success
|
|
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
|
|
assert_line 'trusted su-dummy-b'
|
|
assert_line 'trusted su-dummy-c'
|
|
assert_line '3 upgradable, 3 trusted (hidden, use --all to show), 0 to review:'
|
|
run cut -f2,3 "$STATE_FILE"
|
|
assert_line "su-dummy-b${T}user"
|
|
}
|
|
|
|
@test "*- excludes every listed package; *- again re-includes them" {
|
|
run skup --no-refresh <<<$'*-\nl\n*-\nl\n*-\n'
|
|
assert_success
|
|
assert_line 'excluded all 3 listed packages from this run'
|
|
assert_line 're-included all 3 listed packages'
|
|
assert_line '-[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo'
|
|
assert_line 'Excluded from this run: su-dummy-a su-dummy-b su-dummy-c'
|
|
log_has 'apt-mark hold su-dummy-a su-dummy-b su-dummy-c'
|
|
}
|
|
|
|
@test "several tokens on one line run in order" {
|
|
run skup --no-refresh <<<$'1? 2- 3?\nl\nq'
|
|
assert_success
|
|
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
|
|
assert_line 'excluded from this run: su-dummy-b'
|
|
assert_line 'su-dummy-c: dummy package su-dummy-c for skeptical-updater tests'
|
|
assert_line '-[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
}
|
|
|
|
@test "an unknown token prints an error, runs nothing from that line and re-prompts" {
|
|
run skup --no-refresh <<<$'2- bogus\nl\n1+ 3y\nq'
|
|
assert_success
|
|
assert_output --partial "unknown command 'bogus' (h for help)"
|
|
assert_output --partial "unknown command '3y' (h for help)"
|
|
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
|
|
refute_output --partial 'excluded from this run'
|
|
[[ ! -e $STATE_FILE ]]
|
|
}
|
|
|
|
@test "numbers that are not in the list are rejected" {
|
|
run skup --no-refresh <<<$'9?\n0-\n4x\nq'
|
|
assert_success
|
|
assert_output --partial 'no package [9] in the list (l to redraw)'
|
|
assert_output --partial 'no package [0] in the list (l to redraw)'
|
|
assert_output --partial 'no package [4] in the list (l to redraw)'
|
|
log_lacks '^apt-get -y'
|
|
}
|
|
|
|
@test "h and ? print the help" {
|
|
run skup --no-refresh <<<$'h\n?\nq'
|
|
assert_success
|
|
[[ $(grep -c 'N?? full native info' <<<"$output") -eq 2 ]]
|
|
assert_line 'Several tokens can be given on one line, e.g. "1? 3- 4+".'
|
|
}
|
|
|
|
@test "l redraws the list" {
|
|
run skup --no-refresh <<<$'l\nq'
|
|
assert_success
|
|
[[ $(grep -c '^3 upgradable' <<<"$output") -eq 2 ]]
|
|
}
|
|
|
|
@test "q in the middle of a line stops there (later tokens never run)" {
|
|
run skup --no-refresh <<<$'1? q 3x'
|
|
assert_success
|
|
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
|
|
refute_output --partial 'Purge'
|
|
}
|
|
|
|
@test "nothing to upgrade: no prompt, exit 0" {
|
|
FAKE_APT_SIM=none run skup --no-refresh </dev/null
|
|
assert_success
|
|
assert_line 'Nothing to upgrade.'
|
|
refute_output --partial '<enter> upgrade'
|
|
}
|
|
|
|
@test "held (kept back) rows can be reviewed like the others" {
|
|
echo su-dummy-c >"$FAKE_DIR/holds"
|
|
FAKE_APT_SIM=held run skup --no-refresh <<<$'3?\nq'
|
|
assert_success
|
|
assert_line ' [3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]'
|
|
assert_line 'su-dummy-c: dummy package su-dummy-c for skeptical-updater tests'
|
|
}
|