4043787a58
- DU-H1: flags are parsed wherever they sit, so `install --unattended` and `--unattended install` are the same run; any unknown flag, word, or subcommand exits 2 to stderr before a package manager is touched. - DU-H2: every download lands in one private mktemp -d (mode 700) workdir per run, is checked non-empty before sudo tar sees it, and an EXIT/INT/TERM trap cleans up. No fixed /tmp paths remain. - BUG-1: ^t is now toggle-shown — it ticks only the rows the active filter is showing, and @needs expansion stops at the first invasive row, so an invasive package can never be ticked off-screen. - BUG-2: ^t journals what it added, so a second ^t over the same shown set unticks exactly that set; the bind no longer clears the query. - lab: the type verb polls fzf's reported query to a deadline instead of a fixed sleep; marks_settled retries within its deadline. Suite 256/0 host, 214/0 docker (ubuntu:24.04), mutations 24/24 killed (six new mutants re-introduce each bug and all die), lab 6/6 green.
1125 lines
46 KiB
Bash
1125 lines
46 KiB
Bash
#!/bin/bash
|
|
# The picker, driven the way a hand drives it.
|
|
#
|
|
# Nothing here calls `dotup toggle` or `dotup render` directly. The real fzf is
|
|
# launched in a real pty and real keys are pressed into it; every claim the
|
|
# README makes about the picker is then checked against two independent
|
|
# channels, and each assertion says which one it used:
|
|
#
|
|
# --listen what the picker is SHOWING. fzf answers from its own model, so
|
|
# "row 41 is ticked" is a fact rather than a guess about a redraw.
|
|
# the files what the picker WROTE. $DOTUP_STATE/{selected,expanded} is the
|
|
# whole model; a keystroke that does not move them did nothing.
|
|
#
|
|
# The terminal transcript is scraped for exactly one thing -- the header --
|
|
# because that is the one string --listen cannot report.
|
|
#
|
|
# Unlike 00-smoke.sh this does NOT stop at the first failure. The point is a
|
|
# survey of what a real user hits, and an early exit hides the rest of it; so
|
|
# `fail` records and carries on, `die` is for a setup that cannot proceed, and
|
|
# the exit status is the number of failures.
|
|
#
|
|
# It exits non-zero today, and every failure it reports is the product's, not
|
|
# the harness's. BUG-3 (a deliberate empty selection is not restored), BUG-4/6
|
|
# (no tty: fzf's raw error, and the defaults preset written by a picker that
|
|
# never drew) and BUG-7 (an unreadable state file makes every keystroke a
|
|
# silent no-op) were fixed, and the assertions below now hold the FIXED
|
|
# behaviour -- they fail again if it regresses.
|
|
#
|
|
# BUG-1/2, in `promise 3`, were the last three left: ^t over an --exact filter
|
|
# widened along @needs and ticked invasive rows that were not on screen, and
|
|
# ^t ^t was not its own undo because the reverse edges do not retract what the
|
|
# forward ones pulled in. Both are fixed -- ^t is `dotup toggle-shown`, whose
|
|
# forward walk stops at an invasive dependency and whose second press replays
|
|
# a journal of the first -- and those three assertions now hold the FIXED
|
|
# behaviour too. The scenario exits 0, and every remaining NOTE is a NOTE.
|
|
set -u
|
|
|
|
FAILS=0
|
|
fail() { printf 'FAIL: %s\n' "$*"; FAILS=$((FAILS + 1)); }
|
|
die() { printf 'FAIL: %s\n' "$*"; printf 'FAIL: setup cannot continue\n'; exit 1; }
|
|
ok() { printf ' ok %s\n' "$*"; }
|
|
note() { printf ' NOTE %s\n' "$*"; }
|
|
head_() { printf '\n== %s ==\n' "$*"; }
|
|
|
|
# ------------------------------------------------------------------ setup ----
|
|
head_ "setup"
|
|
# expect is a TEST dependency and is installed here rather than baked into the
|
|
# image, so it can never silently satisfy something the product needs.
|
|
sudo apt-get update -qq >/dev/null 2>&1
|
|
sudo apt-get install -y -qq expect >/dev/null 2>&1 || die "could not install expect"
|
|
command -v expect >/dev/null || die "expect is not on PATH after installing it"
|
|
ok "expect installed (test dependency, not a product one)"
|
|
|
|
# ISSUE-2: ~/.local/bin is not on the PATH of the shell that ran the install,
|
|
# so dotup is invoked by absolute path throughout.
|
|
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \
|
|
|| { tail -20 /tmp/init.log; die "chezmoi init --apply"; }
|
|
D=$HOME/.local/bin/dotup
|
|
[ -x "$D" ] || die "no executable at $D"
|
|
ok "public tier applied; dotup at $D"
|
|
|
|
MAN=$HOME/.local/share/dotup/packages.tsv
|
|
[ -f "$MAN" ] || die "no manifest at $MAN"
|
|
|
|
# --------------------------------------------------------------- harness -----
|
|
cat > /tmp/api.sh <<'SH'
|
|
#!/bin/sh
|
|
# Read fzf's LIVE state out of its own --listen HTTP API.
|
|
#
|
|
# api.sh <port> keys every VISIBLE row's key, in screen order
|
|
# api.sh <port> marks "<mark><TAB><key>" per visible row; mark is x, ~ or .
|
|
# api.sh <port> count matchCount (rows the filter is showing)
|
|
# api.sh <port> total totalCount
|
|
# api.sh <port> pos cursor position, 0-based
|
|
# api.sh <port> cur the key under the cursor
|
|
# api.sh <port> query the query fzf has actually READ off the keyboard
|
|
p=$1; a=$2
|
|
j=$(curl -s --max-time 5 "localhost:$p/?limit=500") || exit 1
|
|
[ -n "$j" ] || exit 1
|
|
# Cut to the matches array: `current` repeats a row and `selected` is fzf's
|
|
# multi-select, and neither is "what is on screen".
|
|
m=$(printf '%s' "$j" | sed 's/.*"matches":\[//; s/\],"selected".*//')
|
|
case $a in
|
|
keys) printf '%s' "$m" | grep -o '\\t[pg]:[^"]*' | cut -c3- ;;
|
|
# One object per line first. A match object is NOT always
|
|
# {"index":N,"text":"..."} -- as soon as a query is typed fzf appends
|
|
# "positions":[...] to every match, so anchoring on the closing brace
|
|
# returns nothing at exactly the moment a filter is under test.
|
|
marks) printf '%s' "$m" | sed 's/{"index":/\n{"index":/g' \
|
|
| sed -n 's/.*"text":"\([^"]*\)".*/\1/p' \
|
|
| sed -n 's/^.*\[\(.\)\][^\\]*\\t\([pg]:[^"]*\)$/\1 \2/p' \
|
|
| sed 's/^ /./' ;;
|
|
count) printf '%s' "$j" | grep -o '"matchCount":[0-9]*' | cut -d: -f2 ;;
|
|
total) printf '%s' "$j" | grep -o '"totalCount":[0-9]*' | cut -d: -f2 ;;
|
|
pos) printf '%s' "$j" | grep -o '"position":[0-9]*' | cut -d: -f2 ;;
|
|
cur) printf '%s' "$j" | sed 's/.*"current":{//; s/},"matches".*//' | grep -o '\\t[pg]:[^"]*' | cut -c3- ;;
|
|
# Not `sed 's/.*"query":"//'`: .* is greedy and would anchor on a later
|
|
# occurrence of the word inside a row's own text.
|
|
query) printf '%s' "$j" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 ;;
|
|
raw) printf '%s\n' "$j" ;;
|
|
esac
|
|
SH
|
|
|
|
cat > /tmp/screen.sh <<'SH'
|
|
#!/bin/sh
|
|
# The pty transcript with the escape sequences taken out, so a screen
|
|
# assertion reads the words a human sees rather than the cursor moves between.
|
|
sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b[()][A-B0-9]//g' \
|
|
-e 's/\x1b[=>]//g' -e 's/\x1b\][^\x07]*\x07//g' /tmp/screen.raw 2>/dev/null | tr -d '\r'
|
|
SH
|
|
|
|
cat > /tmp/drive.exp <<'TCL'
|
|
#!/usr/bin/expect -f
|
|
# Drive the real picker in a real pty with real keystrokes.
|
|
#
|
|
# drive.exp <port> <steps-file> <rows> <cols> <command-string>
|
|
#
|
|
# Every wait is NAMED and fails by name. A bare `expect -re {pat} {}` treats a
|
|
# timeout as "carry on", which turns a missed prompt into an unattributed hang
|
|
# three steps later.
|
|
#
|
|
# Every `expect` below is multi-line ON PURPOSE. The one-line form
|
|
# `expect { eof {...} timeout {...} }` matches NOTHING in expect 5.45.4 -- it
|
|
# returns having run no action at all, which is the same silent-timeout trap in
|
|
# a different costume.
|
|
set PORT [lindex $argv 0]
|
|
set STEPS [lindex $argv 1]
|
|
set ROWS [lindex $argv 2]
|
|
set COLS [lindex $argv 3]
|
|
set CMD [join [lrange $argv 4 end] " "]
|
|
|
|
set timeout 25
|
|
log_user 0
|
|
set stty_init "rows $ROWS cols $COLS"
|
|
set ::lastmarks "<none>"
|
|
set ::rc "-"
|
|
set ::sawEOF 0
|
|
# The state directory under test, so `snap` can copy the files the picker is
|
|
# writing at the same instant it samples the screen.
|
|
set ::SNAP $env(DOTUP_SNAP_STATE)
|
|
|
|
proc bail {msg} {
|
|
puts "FAIL: $msg"
|
|
catch { exec sh -c "curl -s -XPOST localhost:$::PORT -d abort >/dev/null 2>&1" }
|
|
exit 1
|
|
}
|
|
|
|
# Read from the pty without blocking. The buffer is finite and fzf's redraws
|
|
# are noisy; a poll loop that never reads eventually wedges the child. eof is
|
|
# RECORDED, not swallowed: once expect matches eof the spawn id is closed and
|
|
# every later expect on it raises.
|
|
proc pump {} {
|
|
if {$::sawEOF} { return }
|
|
if {[catch {
|
|
expect \
|
|
-timeout 0 \
|
|
-re {(?s).+} {} \
|
|
timeout {} \
|
|
eof { set ::sawEOF 1 }
|
|
}]} { set ::sawEOF 1 }
|
|
}
|
|
|
|
proc api {what} {
|
|
if {[catch { exec sh /tmp/api.sh $::PORT $what } out]} { return "" }
|
|
return $out
|
|
}
|
|
|
|
# A single read can catch a TRANSIENT. `reload` empties fzf's list for an
|
|
# instant before the new rows land, so one sample taken at the wrong moment
|
|
# says "0 rows" or "the old rows", and a wait that accepts the first difference
|
|
# it sees is off by one keystroke for the rest of the session. Two reads that
|
|
# agree are a settled screen. This cost two false failures to learn.
|
|
proc marks_settled {} {
|
|
set deadline [expr {[clock milliseconds] + 15000}]
|
|
set last "the picker never held still"
|
|
while {[clock milliseconds] < $deadline} {
|
|
pump
|
|
set a [api marks]
|
|
after 200
|
|
pump
|
|
set b [api marks]
|
|
if {$a eq $b && $a ne ""} {
|
|
# Cross-check the parse against fzf's own matchCount. A reader that
|
|
# quietly returns nothing becomes a fifteen-minute hang somewhere
|
|
# else; this turns it into one named failure, here.
|
|
#
|
|
# RETRY rather than bail: the two reads above and this count are
|
|
# three separate HTTP round trips, so a reload landing between them
|
|
# disagrees for one sample and agrees on the next. Bailing on the
|
|
# first disagreement made a redraw look like a broken reader. It is
|
|
# still a named failure -- just at the deadline, with the last
|
|
# disagreement as the reason.
|
|
set n [llength [split $a "\n"]]
|
|
set c [api count]
|
|
if {![string is integer -strict $c] || $n == $c} { return $a }
|
|
set last "parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON"
|
|
}
|
|
after 100
|
|
}
|
|
bail "marks_settled: $last (15s)"
|
|
}
|
|
|
|
# The same settling, but tolerant: used BEFORE a keystroke, where the picker
|
|
# may legitimately be gone already (the key after an enter or a ^c). Returns
|
|
# empty instead of failing, so "quiet" and "dead" are not confused.
|
|
proc marks_quiet {} {
|
|
set deadline [expr {[clock milliseconds] + 6000}]
|
|
while {[clock milliseconds] < $deadline} {
|
|
pump
|
|
set a [api marks]
|
|
if {$a eq ""} { return "" }
|
|
after 200
|
|
pump
|
|
set b [api marks]
|
|
if {$a eq $b} { return $a }
|
|
}
|
|
return ""
|
|
}
|
|
|
|
# Named wait 1 -- the picker is up, has drawn rows, and has settled.
|
|
proc wait_ready {} {
|
|
set deadline [expr {[clock milliseconds] + 40000}]
|
|
while {[clock milliseconds] < $deadline} {
|
|
pump
|
|
set c [api count]
|
|
if {[string is integer -strict $c] && $c > 0} {
|
|
set m [marks_settled]
|
|
if {$m ne ""} {
|
|
set ::lastmarks $m
|
|
# fzf answers --listen before it is reading the keyboard; the
|
|
# very first keystroke is otherwise dropped, and the session
|
|
# then fails three steps later on a row that never opened.
|
|
after 700
|
|
pump
|
|
return
|
|
}
|
|
}
|
|
after 250
|
|
}
|
|
bail "wait_ready: the picker never answered --listen on port $::PORT"
|
|
}
|
|
|
|
# Named wait 2 -- the keystroke changed what is on screen. Comparing fzf's own
|
|
# rendered rows proves BOTH that the state file moved AND that the reload
|
|
# landed; the README's "the counts move on the same keystroke" is exactly that,
|
|
# so it is the thing worth waiting on.
|
|
proc wait_change {label} {
|
|
set deadline [expr {[clock milliseconds] + 25000}]
|
|
while {[clock milliseconds] < $deadline} {
|
|
set m [marks_settled]
|
|
if {$m ne $::lastmarks} { set ::lastmarks $m; return }
|
|
after 200
|
|
}
|
|
bail "$label: nothing on screen changed after the keystroke"
|
|
}
|
|
|
|
# Named wait 3 -- text on the terminal. Only for what --listen cannot report.
|
|
proc wait_screen {label pat} {
|
|
for {set i 0} {$i < 60} {incr i} {
|
|
pump
|
|
if {[catch { exec sh /tmp/screen.sh } txt]} { set txt "" }
|
|
if {[regexp $pat $txt]} { return }
|
|
after 250
|
|
}
|
|
bail "$label: never saw /$pat/ on the terminal"
|
|
}
|
|
|
|
# Named wait 4 -- a line from the program AFTER fzf has exited (the plan, the
|
|
# confirm prompt, an error). A real expect belongs there: the output is
|
|
# line-oriented by then, so the buffer is the right place to look.
|
|
proc wait_for {label pat} {
|
|
expect {
|
|
-re $pat { return }
|
|
timeout { bail "$label: timed out waiting for /$pat/" }
|
|
eof { set ::sawEOF 1; bail "$label: exited before /$pat/" }
|
|
}
|
|
}
|
|
|
|
# Put the cursor on a named row. Navigation is a FIXTURE, not the thing under
|
|
# test, and counting arrow keys down a list that changes length as groups open
|
|
# is a source of false failures -- so the cursor is placed over the API and
|
|
# every binding under test is still a real keypress.
|
|
proc goto {key} {
|
|
set ks [split [api keys] "\n"]
|
|
set i [lsearch -exact $ks $key]
|
|
if {$i < 0} { bail "goto $key: that row is not on screen" }
|
|
catch { exec sh -c "curl -s -XPOST localhost:$::PORT -d 'pos([expr {$i + 1}])' >/dev/null" }
|
|
for {set n 0} {$n < 40} {incr n} {
|
|
pump
|
|
if {[api cur] eq $key} { set ::lastmarks [marks_settled]; return }
|
|
after 100
|
|
}
|
|
bail "goto $key: the cursor never landed on it"
|
|
}
|
|
|
|
exec sh -c "rm -f /tmp/screen.raw; : > /tmp/screen.raw"
|
|
# `script` rather than a bare spawn: expect's own log_file records nothing
|
|
# under `log_user 0`, and the transcript is the only channel that shows the
|
|
# header. Same `script -qec` idiom .tests/listen-test.sh already uses.
|
|
eval spawn -noecho script -q -f -e -c [list $CMD] /tmp/screen.raw
|
|
|
|
set fh [open $STEPS r]
|
|
while {[gets $fh line] >= 0} {
|
|
set line [string trim $line]
|
|
if {$line eq "" || [string index $line 0] eq "#"} { continue }
|
|
set verb [lindex $line 0]
|
|
set rest [lrange $line 1 end]
|
|
switch -- $verb {
|
|
ready { wait_ready }
|
|
key {
|
|
# Wait for the screen to go quiet BEFORE pressing, so the key can
|
|
# never land in the middle of the previous reload and be lost --
|
|
# and so the baseline the next `wait` compares against is the
|
|
# state this key acted on, not an older one.
|
|
set m [marks_quiet]
|
|
if {$m ne ""} { set ::lastmarks $m }
|
|
send -- [subst -nocommands -novariables [lindex $rest 1]]
|
|
after 200
|
|
}
|
|
type {
|
|
# fzf reads the keyboard asynchronously, so a fixed sleep after the
|
|
# last character samples whatever it happens to have consumed by
|
|
# then. That is how `nvidia` was once measured as `n` -- 59 rows
|
|
# matching instead of three, and a filtered session that was not
|
|
# filtered. Wait for fzf to REPORT the whole query, then for the
|
|
# rows it produced to hold still.
|
|
set want [lindex $rest 1]
|
|
foreach ch [split $want ""] { send -- $ch; after 80 }
|
|
set deadline [expr {[clock milliseconds] + 15000}]
|
|
set got ""
|
|
while {[clock milliseconds] < $deadline} {
|
|
pump
|
|
set got [api query]
|
|
if {$got eq $want} { break }
|
|
after 100
|
|
}
|
|
if {$got ne $want} { bail "type $want: fzf's query still reads '$got' 15s after the last key" }
|
|
pump; set ::lastmarks [marks_settled] }
|
|
at { goto [lindex $rest 0] }
|
|
wait { wait_change [lindex $rest 0] }
|
|
scr { wait_screen [lindex $rest 0] [lindex $rest 1] }
|
|
line { wait_for [lindex $rest 0] [lindex $rest 1] }
|
|
sleep { pump; after [lindex $rest 0]; pump }
|
|
abort { catch { exec sh -c "curl -s -XPOST localhost:$PORT -d abort >/dev/null" } }
|
|
snap {
|
|
set n [lindex $rest 0]
|
|
set f [open /tmp/snap.$n w]; puts $f [marks_settled]; close $f
|
|
set f [open /tmp/cur.$n w]; puts $f [api cur]; close $f
|
|
set f [open /tmp/cnt.$n w]; puts $f [api count]; close $f
|
|
catch { exec sh -c "cp -f '$::SNAP/selected' /tmp/sel.$n; cp -f '$::SNAP/expanded' /tmp/exp.$n" }
|
|
}
|
|
end {
|
|
set gone $::sawEOF
|
|
if {!$gone} {
|
|
if {[catch {
|
|
expect {
|
|
eof { set gone 1 }
|
|
timeout {}
|
|
}
|
|
}]} { set gone 1 }
|
|
}
|
|
if {!$gone} { bail "end: the picker never exited, 25s after the last key" }
|
|
catch { wait } res
|
|
set ::rc [lindex $res 3]
|
|
set f [open /tmp/rc w]; puts $f $::rc; close $f
|
|
}
|
|
default { bail "unknown step verb: $verb" }
|
|
}
|
|
}
|
|
close $fh
|
|
|
|
if {$::rc eq "-"} {
|
|
catch { exec sh -c "curl -s -XPOST localhost:$PORT -d abort >/dev/null" }
|
|
if {!$::sawEOF} {
|
|
catch {
|
|
expect {
|
|
eof {}
|
|
timeout {}
|
|
}
|
|
}
|
|
}
|
|
catch { wait } res
|
|
set f [open /tmp/rc w]; puts $f [lindex $res 3]; close $f
|
|
}
|
|
puts "DRIVE OK"
|
|
TCL
|
|
|
|
PORTN=0
|
|
DRC=0
|
|
# drive <name> <rows> <cols> <steps-file> <command string>
|
|
drive() {
|
|
dname=$1; drows=$2; dcols=$3; dsteps=$4; shift 4
|
|
PORTN=$((PORTN + 1))
|
|
dport=$((22300 + PORTN))
|
|
rm -f /tmp/rc
|
|
# --listen goes in via FZF_DEFAULT_OPTS so the picker's own argv is
|
|
# untouched: the fzf under test is the one dotup builds, not a variant.
|
|
FZF_DEFAULT_OPTS="--listen $dport" DOTUP_SNAP_STATE="$SNAPST" \
|
|
expect -f /tmp/drive.exp "$dport" "$dsteps" "$drows" "$dcols" "$*" \
|
|
> "/tmp/drive.$dname.log" 2>&1
|
|
DRC=$?
|
|
cp -f /tmp/screen.raw "/tmp/screen.$dname.raw" 2>/dev/null || :
|
|
if [ "$DRC" -ne 0 ]; then
|
|
fail "session '$dname' did not complete: $(grep -m1 '^FAIL' "/tmp/drive.$dname.log" || echo "expect exited $DRC")"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
prc() { cat /tmp/rc 2>/dev/null || echo "-"; }
|
|
# mark of a key in a snapshot: x (all on), ~ (some on), . (off)
|
|
markof() { awk -F'\t' -v k="$2" '$2==k {print $1; found=1} END{ if(!found) print "?" }' "/tmp/snap.$1"; }
|
|
selhas() { grep -qxF "$2" "/tmp/sel.$1" 2>/dev/null; }
|
|
seln() { awk 'NF{n++} END{print n+0}' "/tmp/sel.$1" 2>/dev/null; }
|
|
# keys present in $1 but not $2
|
|
seldiff() { comm -23 <(sort -u "/tmp/sel.$1") <(sort -u "/tmp/sel.$2"); }
|
|
|
|
ST=/tmp/state
|
|
SNAPST=$ST
|
|
export DOTUP_STATE=$ST DOTUP_SNAP_STATE=$ST
|
|
fresh() { rm -rf "$ST"; mkdir -p "$ST"; : > "$ST/selected"; : > "$ST/expanded"; }
|
|
|
|
# =========================================================== PROMISE 1 =======
|
|
# "It brings its own copy into ~/.cache/dotup/ and invokes it by absolute path.
|
|
# PATH is never modified and ~/.local/bin is never written."
|
|
head_ "promise 1 — where fzf comes from"
|
|
|
|
command -v fzf >/dev/null 2>&1 && fail "the image already has fzf; the preflight cannot be observed"
|
|
[ -e "$HOME/.cache/dotup" ] && fail "~/.cache/dotup exists before anything ran"
|
|
|
|
before_bin=$(ls -A "$HOME/.local/bin" | sort | tr '\n' ' ')
|
|
before_rc=$(md5sum "$HOME/.profile" "$HOME/.bashrc" 2>/dev/null | md5sum)
|
|
before_path=$PATH
|
|
|
|
pre=$("$D" preflight 2>&1) || fail "dotup preflight failed: $pre"
|
|
case $pre in
|
|
*"$HOME/.cache/dotup/fzf"*) ok "preflight resolves to ~/.cache/dotup/fzf" ;;
|
|
*) fail "preflight did not use the cache: $pre" ;;
|
|
esac
|
|
[ -x "$HOME/.cache/dotup/fzf" ] || fail "no executable fzf in ~/.cache/dotup"
|
|
"$HOME/.cache/dotup/fzf" --version >/dev/null 2>&1 || fail "the fetched fzf does not run"
|
|
ok "fzf $("$HOME/.cache/dotup/fzf" --version | awk '{print $1}') fetched into ~/.cache/dotup"
|
|
|
|
after_bin=$(ls -A "$HOME/.local/bin" | sort | tr '\n' ' ')
|
|
after_rc=$(md5sum "$HOME/.profile" "$HOME/.bashrc" 2>/dev/null | md5sum)
|
|
[ "$before_bin" = "$after_bin" ] || fail "~/.local/bin changed across the fzf preflight: [$before_bin] -> [$after_bin]"
|
|
ok "~/.local/bin untouched by the preflight"
|
|
[ "$before_rc" = "$after_rc" ] || fail "the preflight edited ~/.profile or ~/.bashrc"
|
|
ok "no shell rc file was written"
|
|
[ "$before_path" = "$PATH" ] || fail "PATH changed across the preflight"
|
|
case ":$PATH:" in *":$HOME/.cache/dotup:"*) fail "the fzf cache was put on PATH" ;; esac
|
|
ok "PATH never mentions the cache"
|
|
|
|
# "Your own fzf wins whenever it clears the floor" -- with the cache already
|
|
# populated, it does not. The cache is checked first, unconditionally.
|
|
mkdir -p /tmp/shim
|
|
printf '#!/bin/sh\necho "0.99.0 (devel)"\n' > /tmp/shim/fzf; chmod +x /tmp/shim/fzf
|
|
got=$(PATH=/tmp/shim:$PATH "$D" fzf-path 2>/dev/null)
|
|
if [ "$got" = "$HOME/.cache/dotup/fzf" ]; then
|
|
note "a system fzf 0.99.0 does NOT win once the cache exists — resolution is
|
|
cache-first, so the README's 'your own fzf wins whenever it clears the
|
|
floor' holds only until dotup has fetched once (BUG-5)"
|
|
else
|
|
ok "the system fzf wins over the cache"
|
|
fi
|
|
# With no cache, a system fzf above the floor must win and nothing may be fetched.
|
|
mv "$HOME/.cache/dotup" /tmp/cachehold
|
|
got=$(PATH=/tmp/shim:$PATH "$D" fzf-path 2>/dev/null)
|
|
[ "$got" = "/tmp/shim/fzf" ] || fail "with no cache, a system fzf 0.99.0 did not win (got '$got')"
|
|
[ -e "$HOME/.cache/dotup/fzf" ] && fail "a usable system fzf was present and dotup fetched anyway"
|
|
ok "with no cache, a system fzf above the floor wins and nothing is fetched"
|
|
# Below the floor it must say so and fetch its own.
|
|
printf '#!/bin/sh\necho "0.30.0 (devel)"\n' > /tmp/shim/fzf
|
|
out=$(PATH=/tmp/shim:$PATH "$D" preflight 2>&1)
|
|
case $out in
|
|
*"below the verified floor"*) ok "a system fzf under the 0.44.0 floor is refused, by name" ;;
|
|
*) fail "an fzf below the floor was accepted silently: $out" ;;
|
|
esac
|
|
rm -rf "$HOME/.cache/dotup"; mv /tmp/cachehold "$HOME/.cache/dotup"
|
|
|
|
# =========================================================== PROMISE 2 =======
|
|
# Every documented binding: space tick, tab open, ^t tick all shown,
|
|
# ^a defaults, ^x none, enter install. (^o open all is in the on-screen header
|
|
# but not in the README prose.)
|
|
head_ "promise 2 — the documented keybindings"
|
|
fresh
|
|
cat > /tmp/steps.keys <<'EOS'
|
|
ready
|
|
scr header {space tick tab open}
|
|
snap p2start
|
|
key ctrl-x \x18
|
|
wait ctrl-x
|
|
snap p2none
|
|
key ctrl-a \x01
|
|
wait ctrl-a
|
|
snap p2defaults
|
|
key ctrl-o \x0f
|
|
wait ctrl-o
|
|
snap p2openall
|
|
key ctrl-o \x0f
|
|
wait ctrl-o-again
|
|
snap p2closed
|
|
at g:media
|
|
key tab \t
|
|
wait tab
|
|
snap p2tab
|
|
key space \x20
|
|
wait space-on-group
|
|
snap p2groupoff
|
|
key enter \r
|
|
line plan {plan}
|
|
line count {[0-9]+ packages}
|
|
end
|
|
EOS
|
|
if drive keys 44 220 /tmp/steps.keys "$D pick"; then
|
|
# header: the one screen scrape. --listen cannot report it.
|
|
scr=$(sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/screen.keys.raw | tr -d '\r')
|
|
case $scr in
|
|
*'$space tick'*) fail "the header renders with a literal \$ — the \$'...' bashism is back
|
|
(dot_local/bin/executable_dotup, cmd_pick; /bin/sh is dash here)" ;;
|
|
*) ok "header has no stray \$ (the dash bashism stays fixed)" ;;
|
|
esac
|
|
case $scr in
|
|
*'install\n'*) fail "the header ends with a literal backslash-n — bashism regression" ;;
|
|
*) ok "header has no literal backslash-n" ;;
|
|
esac
|
|
case $scr in *'enter install'*) ok "header advertises 'enter install'" ;;
|
|
*) fail "the header never reached the screen" ;; esac
|
|
|
|
[ "$(seln p2none)" -eq 0 ] || fail "^x left $(seln p2none) rows ticked"
|
|
ok "^x none — selection emptied ($(seln p2start) -> 0)"
|
|
[ "$(seln p2defaults)" -gt 0 ] || fail "^a did not restore any selection"
|
|
ok "^a defaults — $(seln p2defaults) rows back"
|
|
o=$(cat /tmp/cnt.p2openall); c=$(cat /tmp/cnt.p2closed)
|
|
[ "$o" -gt "$c" ] || fail "^o did not open the tree ($c -> $o rows)"
|
|
ok "^o open all — $c rows -> $o rows"
|
|
[ "$c" -eq 11 ] || note "^o is a TOGGLE, not 'open all': the second press closed everything ($c rows)"
|
|
grep -qx media /tmp/exp.p2tab || fail "tab on g:media did not record media as expanded"
|
|
ok "tab open — g:media expanded, $(cat /tmp/cnt.p2tab) rows on screen"
|
|
if [ "$(markof p2groupoff g:media)" = "." ]; then
|
|
ok "space tick — space on a full group turned every child off"
|
|
else
|
|
fail "space on a full g:media left it marked '$(markof p2groupoff g:media)'"
|
|
fi
|
|
[ "$(prc)" = "0" ] || fail "enter did not exit the picker cleanly (rc=$(prc))"
|
|
ok "enter install — picker accepted and the plan printed (rc 0)"
|
|
else
|
|
fail "promise 2 session aborted"
|
|
fi
|
|
|
|
# ^t needs its own session so the filtered case below starts from a known set.
|
|
fresh
|
|
cat > /tmp/steps.ctrlt <<'EOS'
|
|
ready
|
|
snap p2tA
|
|
key ctrl-t \x14
|
|
wait ctrl-t
|
|
snap p2tB
|
|
abort
|
|
end
|
|
EOS
|
|
if drive ctrlt 44 220 /tmp/steps.ctrlt "$D pick"; then
|
|
a=$(seln p2tA); b=$(seln p2tB)
|
|
[ "$a" -ne "$b" ] || fail "^t with no filter changed nothing ($a -> $b)"
|
|
ok "^t tick all shown — $a -> $b rows ticked with no filter"
|
|
risky=$(comm -23 <(sort -u /tmp/sel.p2tB) <(sort -u /tmp/sel.p2tA) | while read -r k; do
|
|
awk -F'\t' -v k="$k" '!/^[#@]/ && NF>=3 && ($1"/"$2)==k && ($3=="invasive"||$3=="private") {print k}' "$MAN"
|
|
done | wc -l)
|
|
note "one ^t on the default collapsed screen ticks $risky invasive/private rows.
|
|
Every group row is 'shown', so 'tick all shown' means the whole manifest
|
|
-- kernel drivers, the docker daemon and the display manager included"
|
|
fi
|
|
|
|
# =========================================================== PROMISE 3 =======
|
|
# "Expand the row to the packages it covers; if every one is on, turn them all
|
|
# off, otherwise turn them all on." Group row, package row, filtered set.
|
|
head_ "promise 3 — the one toggle rule"
|
|
|
|
fresh
|
|
"$D" preset defaults
|
|
cat > /tmp/steps.rule <<'EOS'
|
|
ready
|
|
key ctrl-o \x0f
|
|
wait open-all
|
|
at g:media
|
|
snap r0
|
|
key space \x20
|
|
wait media-off
|
|
snap r1
|
|
key space \x20
|
|
wait media-on
|
|
snap r2
|
|
at p:media/sox
|
|
key space \x20
|
|
wait sox-off
|
|
snap r3
|
|
key space \x20
|
|
wait sox-on
|
|
snap r4
|
|
abort
|
|
end
|
|
EOS
|
|
if drive rule 44 220 /tmp/steps.rule "$D pick"; then
|
|
for k in media/ffmpeg media/sox media/p7zip; do
|
|
selhas r0 "$k" || fail "$k was not on at the start"
|
|
selhas r1 "$k" && fail "group row: all-on did not turn $k off"
|
|
selhas r2 "$k" || fail "group row: the second press did not turn $k back on"
|
|
done
|
|
ok "group row — all three of media went off together, then all back on"
|
|
[ "$(markof r1 g:media)" = "." ] || fail "g:media mark after all-off is '$(markof r1 g:media)'"
|
|
[ "$(markof r2 g:media)" = "x" ] || fail "g:media mark after all-on is '$(markof r2 g:media)'"
|
|
ok "group mark tracked it: x -> . -> x"
|
|
|
|
selhas r3 media/sox && fail "package row: space did not untick media/sox"
|
|
selhas r3 media/ffmpeg || fail "package row: space on sox also unticked ffmpeg"
|
|
ok "package row — space on media/sox moved sox and nothing else"
|
|
[ "$(markof r3 g:media)" = "~" ] || fail "g:media should read '~' with 2 of 3 on, reads '$(markof r3 g:media)'"
|
|
ok "the group above it went tri-state '~' on the same keystroke"
|
|
selhas r4 media/sox || fail "the second space did not put media/sox back"
|
|
ok "and back again"
|
|
fi
|
|
|
|
# The filtered case, verbatim from the README:
|
|
# "type nvidia, press ^t, and exactly the three rows you can see flip."
|
|
fresh
|
|
"$D" preset defaults
|
|
cat > /tmp/steps.filter <<'EOS'
|
|
ready
|
|
key ctrl-o \x0f
|
|
wait open-all
|
|
snap f0
|
|
type q nvidia
|
|
snap f1
|
|
key ctrl-t \x14
|
|
wait ctrl-t-on
|
|
snap f2
|
|
key ctrl-t \x14
|
|
wait ctrl-t-off
|
|
snap f3
|
|
abort
|
|
end
|
|
EOS
|
|
if drive filter 44 220 /tmp/steps.filter "$D pick"; then
|
|
vis=$(cut -f2 /tmp/snap.f1 | sort | tr '\n' ' ')
|
|
n=$(cat /tmp/cnt.f1)
|
|
[ "$n" -eq 3 ] || fail "typing 'nvidia' shows $n rows, README says three: $vis"
|
|
[ "$vis" = "p:gpu/container-toolkit p:gpu/cuda-toolkit p:gpu/nvidia-driver " ] \
|
|
|| fail "the three visible rows are not the gpu ones: $vis"
|
|
ok "--exact confines 'nvidia' to exactly three rows: $vis"
|
|
|
|
added=$(seldiff f2 f1 | tr '\n' ' ')
|
|
if [ "$added" = "gpu/container-toolkit gpu/cuda-toolkit gpu/nvidia-driver " ]; then
|
|
ok "^t over the filter flipped exactly the rows on screen"
|
|
else
|
|
fail "^t over a filtered set ticked rows that are NOT on screen.
|
|
README: 'exactly the three rows you can see flip'.
|
|
Actually ticked: $added
|
|
The extras come from @needs gpu/container-toolkit docker, and every one
|
|
of them is flagged invasive — docker-ce's own note is 'the docker group
|
|
is root-equivalent'. Nothing on screen says it happened. (BUG-1)"
|
|
fi
|
|
offscreen=$(for k in $(seldiff f2 f1); do
|
|
grep -qxF "p:$k" <(cut -f2 /tmp/snap.f1) || echo "$k"; done)
|
|
inv=$(for k in $offscreen; do
|
|
awk -F'\t' -v k="$k" '!/^[#@]/ && NF>=3 && ($1"/"$2)==k && $3=="invasive" {print k}' "$MAN"; done | tr '\n' ' ')
|
|
if [ -n "$offscreen" ]; then
|
|
fail "the risk model says 'invasive is never ticked for you'. One ^t ticked
|
|
these rows that were NOT on screen: $(printf '%s' "$offscreen" | tr '\n' ' ')
|
|
and every one of them is invasive: $inv (BUG-1, same keystroke)"
|
|
fi
|
|
|
|
# and the same keystroke twice in a row is not an undo
|
|
if diff -q <(sort -u /tmp/sel.f1) <(sort -u /tmp/sel.f3) >/dev/null; then
|
|
ok "^t then ^t returns to where it started"
|
|
else
|
|
still=$(seldiff f3 f1 | tr '\n' ' ')
|
|
fail "^t is not its own undo over a filter: after ^t ^t these are still
|
|
ticked that were not before: $still
|
|
Turning ON widens along @needs; turning OFF widens along the reverse
|
|
edges, and nothing needs the gpu rows — so the docker packages the first
|
|
press pulled in are never let go. (BUG-2)"
|
|
fi
|
|
fi
|
|
|
|
# =========================================================== PROMISE 4 =======
|
|
# "@needs closure runs in both directions ... the counts move on the same
|
|
# keystroke."
|
|
head_ "promise 4 — the @needs closure, both directions"
|
|
fresh
|
|
"$D" preset defaults
|
|
cat > /tmp/steps.needs <<'EOS'
|
|
ready
|
|
key ctrl-o \x0f
|
|
wait open-all
|
|
at p:networking/xrdp
|
|
snap n0
|
|
key space \x20
|
|
wait xrdp-on
|
|
snap n1
|
|
at p:core/node
|
|
key space \x20
|
|
wait node-off
|
|
snap n2
|
|
abort
|
|
end
|
|
EOS
|
|
if drive needs 44 220 /tmp/steps.needs "$D pick"; then
|
|
selhas n1 networking/xrdp || fail "space did not tick networking/xrdp"
|
|
for k in desktop/xfce4 desktop/lightdm; do
|
|
selhas n0 "$k" && fail "$k was already on before the keystroke"
|
|
selhas n1 "$k" || fail "ticking xrdp did not pull in $k"
|
|
done
|
|
ok "forward — one space on xrdp ticked the whole desktop group"
|
|
[ "$(markof n0 g:desktop)" = "." ] || fail "g:desktop did not start empty"
|
|
[ "$(markof n1 g:desktop)" = "x" ] || fail "g:desktop reads '$(markof n1 g:desktop)' after the keystroke, not 'x'"
|
|
ok "and the count moved on that same keystroke: g:desktop . -> x"
|
|
|
|
dropped=$(seldiff n1 n2 | tr '\n' ' ')
|
|
for k in core/node agents/codex agents/pi core/mermaid-cli core/neovim; do
|
|
selhas n2 "$k" && fail "unticking core/node did not drop $k"
|
|
done
|
|
ok "reverse — one space on core/node dropped node, codex, pi, mermaid-cli, neovim"
|
|
case " $dropped " in
|
|
*" agents/pi-plugins "*)
|
|
note "it also drops agents/pi-plugins, which the README's list omits.
|
|
Dropped set: $dropped" ;;
|
|
esac
|
|
[ "$(markof n2 g:agents)" = "~" ] || fail "g:agents reads '$(markof n2 g:agents)' after node went off, not '~'"
|
|
[ "$(markof n2 g:core)" = "~" ] || fail "g:core reads '$(markof n2 g:core)' after node went off, not '~'"
|
|
ok "both group counts moved on that same keystroke: g:agents x -> ~, g:core x -> ~"
|
|
fi
|
|
|
|
# =========================================================== PROMISE 5 =======
|
|
# safe pre-ticked, invasive never, gui only with a display, private later.
|
|
head_ "promise 5 — the risk model as invariants"
|
|
fresh
|
|
cat > /tmp/steps.risk <<'EOS'
|
|
ready
|
|
key ctrl-o \x0f
|
|
wait open-all
|
|
snap risk
|
|
abort
|
|
end
|
|
EOS
|
|
if drive risk 44 220 /tmp/steps.risk "$D pick"; then
|
|
bad=0
|
|
while IFS=" " read -r g p f rest; do
|
|
case $g in \#*|@*) continue ;; esac
|
|
[ -n "${f:-}" ] || continue
|
|
on=0; selhas risk "$g/$p" && on=1
|
|
case $f in
|
|
safe) [ "$on" -eq 1 ] || { fail "safe row $g/$p was NOT pre-ticked"; bad=1; } ;;
|
|
invasive) [ "$on" -eq 0 ] || { fail "invasive row $g/$p WAS pre-ticked"; bad=1; } ;;
|
|
private) [ "$on" -eq 0 ] || { fail "private row $g/$p WAS pre-ticked"; bad=1; } ;;
|
|
gui) [ "$on" -eq 0 ] || { fail "gui row $g/$p was pre-ticked with no display"; bad=1; } ;;
|
|
esac
|
|
done < "$MAN"
|
|
[ "$bad" -eq 0 ] && ok "with no DISPLAY: every safe row on, every gui/invasive/private row off"
|
|
[ "$(markof risk g:gpu)" = "." ] || fail "g:gpu is pre-ticked"
|
|
[ "$(markof risk g:private)" = "." ] || fail "g:private is pre-ticked"
|
|
[ "$(markof risk g:apps)" = "." ] || fail "g:apps (gui) is pre-ticked with no display"
|
|
ok "the invasive, private and gui group rows all read '.' on first draw"
|
|
fi
|
|
# and with a display, gui comes on and nothing else moves
|
|
fresh
|
|
DISPLAY=:0 "$D" preset defaults
|
|
g_on=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="gui" {print $1"/"$2}' "$MAN" | while read -r k; do grep -qxF "$k" "$ST/selected" && echo "$k"; done | wc -l)
|
|
g_all=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="gui"' "$MAN" | wc -l)
|
|
[ "$g_on" -eq "$g_all" ] || fail "with DISPLAY=:0 only $g_on of $g_all gui rows are ticked"
|
|
i_on=$(awk -F'\t' '!/^[#@]/ && NF>=3 && ($3=="invasive"||$3=="private") {print $1"/"$2}' "$MAN" | while read -r k; do grep -qxF "$k" "$ST/selected" && echo "$k"; done | wc -l)
|
|
[ "$i_on" -eq 0 ] || fail "with DISPLAY=:0, $i_on invasive/private rows became ticked"
|
|
ok "with DISPLAY set: all $g_all gui rows on, still zero invasive/private"
|
|
|
|
# private is scheduled, not done: ticking it must not ask for anything at
|
|
# picker time. --print resolves and prints, installs nothing.
|
|
fresh
|
|
"$D" preset none
|
|
cat > /tmp/steps.priv <<'EOS'
|
|
ready
|
|
at g:private
|
|
key space \x20
|
|
wait private-on
|
|
snap priv
|
|
key enter \r
|
|
line plan {plan}
|
|
line confirm {install\? \[y/N\]}
|
|
key yes y\r
|
|
line privsection {private}
|
|
end
|
|
EOS
|
|
if drive priv 44 220 /tmp/steps.priv "$D --print"; then
|
|
selhas priv private/private-repo || fail "space on g:private did not tick private-repo"
|
|
ok "a private row can be ticked in the picker"
|
|
scr=$(sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/screen.priv.raw | tr -d '\r')
|
|
case $scr in
|
|
*"prompt for URL, username, password (interactive only)"*)
|
|
ok "the password is scheduled for after the install, not asked at pick time" ;;
|
|
*) fail "the private tier never announced its deferred prompt" ;;
|
|
esac
|
|
pre=$(printf '%s' "$scr" | sed -n '1,/plan/p')
|
|
case $pre in *Password*) fail "a password prompt appeared BEFORE the plan" ;; esac
|
|
ok "nothing asked for a password while the picker was up"
|
|
# private is never a package
|
|
case $scr in
|
|
*"private/private-repo"*) fail "a private row reached the install plan" ;;
|
|
*) ok "the private rows never reach the plan (private is never a package)" ;;
|
|
esac
|
|
fi
|
|
# and a stale state file that ticks invasive cannot get past --unattended
|
|
fresh
|
|
awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="invasive" {print $1"/"$2}' "$MAN" > "$ST/selected"
|
|
out=$("$D" --unattended --print install 2>&1)
|
|
case $out in
|
|
*"refusing invasive packages"*) ok "--unattended refuses an invasive selection left by a stale state file" ;;
|
|
*) fail "--unattended installed from a stale invasive selection" ;;
|
|
esac
|
|
|
|
# =========================================================== PROMISE 6 =======
|
|
# What the picker writes, and that a second run restores it.
|
|
head_ "promise 6 — what is written, and what a second run restores"
|
|
# Deliberately NOT $DOTUP_STATE: this is the documented default location.
|
|
unset DOTUP_STATE
|
|
DEF=$HOME/.config/dotfiles
|
|
SNAPST=$DEF
|
|
export DOTUP_SNAP_STATE=$DEF
|
|
rm -rf "$DEF"
|
|
cat > /tmp/steps.write <<'EOS'
|
|
ready
|
|
key ctrl-x \x18
|
|
wait ctrl-x
|
|
at g:media
|
|
key space \x20
|
|
wait media-on
|
|
key tab \t
|
|
wait tab
|
|
snap w1
|
|
key enter \r
|
|
end
|
|
EOS
|
|
if drive write 44 220 /tmp/steps.write "$D pick"; then
|
|
[ -f "$DEF/selected" ] || fail "no $DEF/selected after the picker exited"
|
|
[ -f "$DEF/expanded" ] || fail "no $DEF/expanded after the picker exited"
|
|
ok "the picker writes ~/.config/dotfiles/{selected,expanded}"
|
|
got=$(sort "$DEF/selected" | tr '\n' ' ')
|
|
[ "$got" = "media/ffmpeg media/p7zip media/sox " ] \
|
|
|| fail "selected holds '$got', not the three media rows that were ticked"
|
|
ok "selected holds exactly what was ticked: $got"
|
|
grep -qx media "$DEF/expanded" || fail "expanded does not record the group that was opened"
|
|
ok "expanded records the open group"
|
|
mode=$(stat -c %a "$DEF/selected")
|
|
note "state files are mode $mode"
|
|
fi
|
|
cat > /tmp/steps.restore <<'EOS'
|
|
ready
|
|
snap w2
|
|
abort
|
|
end
|
|
EOS
|
|
if drive restore 44 220 /tmp/steps.restore "$D pick"; then
|
|
[ "$(markof w2 g:media)" = "x" ] || fail "a second run did not restore the media selection (mark '$(markof w2 g:media)')"
|
|
[ "$(markof w2 g:core)" = "." ] || fail "a second run re-ticked g:core, which had been cleared"
|
|
ok "a second run comes up on the same selection"
|
|
grep -q 'p:media/' <(cut -f2 /tmp/snap.w2) || fail "a second run did not restore the open group"
|
|
ok "and with the same group still open"
|
|
fi
|
|
# the empty selection is the one a second run does not restore
|
|
rm -rf "$DEF"
|
|
cat > /tmp/steps.none <<'EOS'
|
|
ready
|
|
key ctrl-x \x18
|
|
wait ctrl-x
|
|
key enter \r
|
|
end
|
|
EOS
|
|
if drive nonesel 44 220 /tmp/steps.none "$D pick"; then
|
|
[ -s "$DEF/selected" ] && fail "^x then enter left a non-empty selection"
|
|
if drive nonesel2 44 220 /tmp/steps.restore "$D pick"; then
|
|
if [ "$(markof w2 g:core)" = "x" ]; then
|
|
fail "a deliberate empty selection is NOT restored: the second run silently
|
|
re-applied the defaults preset, because cmd_pick tests \`[ -s \$SEL ]\`
|
|
and an empty file is indistinguishable from a missing one. Choosing
|
|
'none' and pressing enter cannot be made to stick. (BUG-3)"
|
|
else
|
|
ok "an empty selection survives a second run"
|
|
fi
|
|
fi
|
|
fi
|
|
export DOTUP_STATE=$ST; SNAPST=$ST; export DOTUP_SNAP_STATE=$ST
|
|
|
|
# ============================================================ HOSTILE ========
|
|
head_ "hostile 1 — no TTY"
|
|
fresh
|
|
# This scenario shell has no controlling terminal at all (docker exec without
|
|
# -t), which is exactly a CI runner or a cron job. Nothing is redirected: the
|
|
# tty is genuinely absent, which is what fzf actually checks -- it opens
|
|
# /dev/tty directly, so a redirect of stdin would not reproduce this.
|
|
out=$("$D" pick 2>&1); rc=$?
|
|
[ "$rc" -ne 0 ] || fail "the picker claimed success with no terminal"
|
|
ok "dotup pick fails with no tty (rc=$rc)"
|
|
printf '%s\n' "$out" | sed 's/^/ | /' | head -5
|
|
case $out in
|
|
*unattended*|*--unattended*) ok "the failure names --unattended as the way out" ;;
|
|
*) fail "with no tty the user gets fzf's raw '$(printf '%s' "$out" | head -1)' and no
|
|
mention of --unattended, which is the documented answer for a machine
|
|
with nobody at the keyboard (BUG-4)" ;;
|
|
esac
|
|
if [ -s "$ST/selected" ]; then
|
|
fail "the failed picker still wrote $(grep -c . "$ST/selected") rows into the state file —
|
|
cmd_pick applies the defaults preset BEFORE fzf runs, so a run that
|
|
never drew anything still changed what a later run will install (BUG-6)"
|
|
else
|
|
ok "a picker that never drew anything left the state file alone"
|
|
fi
|
|
|
|
head_ "hostile 2 — a 10x40 terminal"
|
|
fresh
|
|
cat > /tmp/steps.tiny <<'EOS'
|
|
ready
|
|
snap t1
|
|
key space \x20
|
|
wait space
|
|
snap t2
|
|
key enter \r
|
|
end
|
|
EOS
|
|
if drive tiny 10 40 /tmp/steps.tiny "$D pick"; then
|
|
ok "the picker comes up on 10 rows x 40 columns ($(cat /tmp/cnt.t1) rows listed)"
|
|
[ "$(seln t1)" -ne "$(seln t2)" ] || fail "space did nothing on a tiny terminal"
|
|
ok "space still toggles there ($(seln t1) -> $(seln t2) rows ticked)"
|
|
[ "$(prc)" = "0" ] || fail "enter did not exit cleanly on a tiny terminal (rc=$(prc))"
|
|
ok "enter still accepts there"
|
|
scr=$(sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/screen.tiny.raw | tr -d '\r')
|
|
case $scr in
|
|
*'space tick'*) note "the header is still (partly) visible at 40 columns" ;;
|
|
*) note "at 40 columns the header is off screen entirely — the only place the
|
|
keybindings are documented in the UI" ;;
|
|
esac
|
|
fi
|
|
|
|
head_ "hostile 3 — fzf missing and unfetchable"
|
|
fresh
|
|
export XDG_CACHE_HOME=/tmp/nocache; rm -rf /tmp/nocache
|
|
# No network, simulated at the only place dotup reaches for one. Docker's
|
|
# default profile refuses `unshare -n` to an unprivileged user, so the network
|
|
# is removed from dotup's point of view instead of from the container's. PATH
|
|
# is modified BY THE TEST here; dotup is still the thing that must not modify it.
|
|
mkdir -p /tmp/nonet
|
|
printf '#!/bin/sh\nexit 6\n' > /tmp/nonet/curl; chmod +x /tmp/nonet/curl
|
|
# Two things are wrong at once here -- there is no terminal AND no fzf can be
|
|
# fetched -- and cmd_pick now answers the terminal first. The /dev/tty probe
|
|
# was moved AHEAD of ensure_fzf on purpose (a run in a pipe used to print
|
|
# fzf's raw error having already overwritten the selection with the defaults
|
|
# preset), so with no tty the fzf message is unreachable by definition. This
|
|
# arm asserts the ORDER; the pty arm below asserts the fzf message.
|
|
out=$(PATH=/tmp/nonet:$PATH "$D" pick 2>&1); rc=$?
|
|
[ "$rc" -eq 2 ] || fail "with neither a terminal nor a fetchable fzf, pick exits $rc, not 2"
|
|
ok "dotup pick exits 2 when fzf cannot be had"
|
|
printf '%s\n' "$out" | sed 's/^/ | /'
|
|
case $out in
|
|
*"no terminal"*) ok "the /dev/tty probe answers before ensure_fzf is reached" ;;
|
|
*) fail "with no terminal the tty probe must answer first: got '$(printf '%s' "$out" | head -1)'" ;;
|
|
esac
|
|
[ -s "$ST/selected" ] && fail "a pick that failed the tty probe still wrote a selection"
|
|
|
|
# The same missing fzf, with a terminal attached. That is now the only way to
|
|
# reach ensure_fzf's error at all, so the message is checked through a pty:
|
|
# `script -e` hands the child a real /dev/tty and returns the child's status.
|
|
rm -rf /tmp/nocache
|
|
: > /tmp/nofzf.log
|
|
PATH=/tmp/nonet:$PATH script -q -e -c "$D pick" /tmp/nofzf.log >/dev/null 2>&1; rc=$?
|
|
nofzf=$(tr -d '\r' < /tmp/nofzf.log)
|
|
[ "$rc" -eq 2 ] || fail "with a terminal but no fetchable fzf, pick exits $rc, not 2"
|
|
ok "with a terminal attached and no fzf to be had, dotup pick exits 2"
|
|
printf '%s\n' "$nofzf" | sed 's/^/ | /'
|
|
case $nofzf in
|
|
*"numbered prompt"*) fail "the error still promises a numbered prompt that does not exist" ;;
|
|
esac
|
|
case $nofzf in
|
|
*"dotup preset defaults && dotup install"*) ok "it names a fallback that exists" ;;
|
|
*) fail "the error names no usable way forward" ;;
|
|
esac
|
|
# and the advice has to actually work
|
|
PATH=/tmp/nonet:$PATH "$D" preset defaults 2>/dev/null || fail "the suggested 'dotup preset defaults' fails"
|
|
[ -s "$ST/selected" ] || fail "the suggested fallback selected nothing"
|
|
PATH=/tmp/nonet:$PATH "$D" --print install >/tmp/fallback.log 2>&1 \
|
|
|| fail "the suggested 'dotup install' fails: $(tail -2 /tmp/fallback.log)"
|
|
grep -q 'apt-get install' /tmp/fallback.log || fail "the fallback resolved no commands"
|
|
ok "and the fallback it names really does resolve the same $(grep -c . "$ST/selected") rows"
|
|
unset XDG_CACHE_HOME
|
|
|
|
head_ "hostile 4 — a hand-edited state file"
|
|
fresh
|
|
printf 'core/eza\r\ncore/ripgrep\n\n core/htop\ncore/bat\ncore/bat\ng:core\nnot/a-package\n' > "$ST/selected"
|
|
printf 'core\nnosuchgroup\n' > "$ST/expanded"
|
|
# The hand-edited file is only under test if the picker actually reads it.
|
|
# Seeding the defaults preset is now decided by "has a pick ever completed"
|
|
# ($STATE/picked), not by "$SEL is empty" -- emptiness could not tell a
|
|
# deliberate `^x`-then-enter apart from a brand new machine. So a state
|
|
# directory with no `picked` in it gets the 43 default rows written over the
|
|
# top of the junk BEFORE anything draws, and the session below would then be
|
|
# testing the preset rather than the hand edit. Every machine that has ever
|
|
# pressed enter has this marker; the fixture needs it too.
|
|
: > "$ST/picked"
|
|
cat > /tmp/steps.corrupt <<'EOS'
|
|
ready
|
|
snap c1
|
|
at p:core/tree
|
|
key space \x20
|
|
wait space-after-corrupt
|
|
snap c2
|
|
abort
|
|
end
|
|
EOS
|
|
if drive corrupt 44 220 /tmp/steps.corrupt "$D pick"; then
|
|
ok "the picker survives junk in selected/expanded and draws $(cat /tmp/cnt.c1) rows"
|
|
[ "$(markof c1 p:core/ripgrep)" = "x" ] || fail "a clean line in a junk file was not honoured"
|
|
if [ "$(markof c1 p:core/eza)" = "x" ]; then
|
|
ok "the CRLF line was tolerated"
|
|
else
|
|
note "a CRLF line ('core/eza\\r') is silently ignored — the row reads unticked
|
|
and nothing says why, which is what a Windows-side edit or a pasted
|
|
file produces"
|
|
fi
|
|
[ "$(markof c1 p:core/htop)" = "." ] || note "a leading-space line was honoured"
|
|
[ "$(markof c1 p:core/bat)" = "x" ] || fail "the duplicated clean key was not honoured"
|
|
ok "a duplicated key is read once, not twice"
|
|
if selhas c2 core/tree; then
|
|
ok "space still toggles with junk in the file (core/tree went on)"
|
|
else
|
|
fail "space stopped working after junk in the state file: core/tree is not in
|
|
$(seln c2) selected line(s) after the keystroke"
|
|
fi
|
|
[ "$(seln c1)" -eq "$(seln c2)" ] && note "the first toggle silently rewrote the whole
|
|
file — sort -u collapsed the duplicate, so the line count did not move
|
|
even though a package was added"
|
|
if grep -qx 'not/a-package' "$ST/selected"; then
|
|
note "the junk lines are preserved verbatim through a toggle (sort -u rewrites
|
|
the file but never validates it); they are inert because every consumer
|
|
joins against the manifest"
|
|
fi
|
|
out=$("$D" --print plan 2>&1)
|
|
case $out in *not/a-package*) fail "a junk key reached the install plan" ;;
|
|
*) ok "no junk key reaches the plan" ;; esac
|
|
fi
|
|
# and the same file with no read permission, which is what a root-run dotup leaves
|
|
fresh
|
|
"$D" preset defaults
|
|
chmod 000 "$ST/selected"
|
|
# There is no session to drive here any more. BUG-7 was that the picker DREW
|
|
# on an unreadable $SEL: every bind is execute-silent, which throws its child's
|
|
# status away, so each tick was discarded in silence and the picker looked
|
|
# alive while being completely inert. Readable-and-writable is now a
|
|
# precondition of drawing, so the assertion is that nothing draws at all --
|
|
# and it has to be made through a pty, because the /dev/tty probe runs first
|
|
# and would otherwise be the thing that answers.
|
|
: > /tmp/permrefuse.log
|
|
script -q -e -c "$D pick" /tmp/permrefuse.log >/dev/null 2>&1; permrc=$?
|
|
permout=$(tr -d '\r' < /tmp/permrefuse.log)
|
|
[ "$permrc" -eq 2 ] || fail "with an unreadable state file, dotup pick exits $permrc, not 2"
|
|
ok "dotup pick refuses to draw on an unreadable state file (rc=$permrc)"
|
|
printf '%s\n' "$permout" | sed 's/^/ | /'
|
|
case $permout in
|
|
*"not readable and writable"*) ok "the refusal names the file, and says the ticks would be lost" ;;
|
|
*) fail "the refusal does not explain itself: '$(printf '%s' "$permout" | head -1)'" ;;
|
|
esac
|
|
case $permout in
|
|
*"delete it, and re-run"*) ok "and it names a way out (fix the ownership, or delete it)" ;;
|
|
*) fail "the refusal names no way out" ;;
|
|
esac
|
|
chmod 644 "$ST/selected" 2>/dev/null || :
|
|
|
|
head_ "hostile 5 — ^c in the middle of the picker"
|
|
fresh
|
|
"$D" preset defaults
|
|
before=$(md5sum "$ST/selected" | cut -d' ' -f1)
|
|
cat > /tmp/steps.sigint <<'EOS'
|
|
ready
|
|
at g:media
|
|
key space \x20
|
|
key ctrlc \x03
|
|
key ctrlc \x03
|
|
sleep 1500
|
|
end
|
|
EOS
|
|
if drive sigint 44 220 /tmp/steps.sigint "$D pick"; then
|
|
ok "^c ends the picker (rc=$(prc))"
|
|
[ "$(prc)" = "1" ] || note "^c leaves dotup exiting $(prc)"
|
|
after=$(md5sum "$ST/selected" | cut -d' ' -f1)
|
|
n=$(grep -c . "$ST/selected")
|
|
if [ "$n" -eq 0 ] && [ "$before" != "$after" ]; then
|
|
fail "^c during a toggle emptied the selection outright"
|
|
fi
|
|
# a valid selection is one where every line is a manifest key
|
|
junk=$(while read -r k; do
|
|
awk -F'\t' -v k="$k" 'BEGIN{f=1} !/^[#@]/ && NF>=3 && ($1"/"$2)==k {f=0} END{exit f}' "$MAN" || echo "$k"
|
|
done < "$ST/selected" | tr '\n' ' ')
|
|
[ -z "$junk" ] || fail "^c left unparseable lines in selected: $junk"
|
|
ok "selected still holds $n valid keys after ^c"
|
|
orphans=$(ls -A "$ST" | grep -c '^\.' || true)
|
|
if [ "$orphans" -gt 0 ]; then
|
|
fail "^c left $orphans temp file(s) behind in $ST: $(ls -A "$ST" | grep '^\.' | tr '\n' ' ')
|
|
cmd_toggle builds \$STATE/.sel.\$\$ and only removes it on success, and
|
|
the final write is \`sort -u \$tmp > \$SEL\` — a redirect, which truncates
|
|
\$SEL before sort has written a byte. Interrupted there, the selection is
|
|
gone and the temp file stays. cmd_expand already does this correctly
|
|
with mv. (BUG-8)"
|
|
else
|
|
ok "no temp files left behind in the state directory"
|
|
note "^c reaches fzf rather than the toggle child (execute-silent does not
|
|
hand over the terminal's foreground group), so this could not be made
|
|
to interrupt a write. The write is still not atomic: cmd_toggle ends
|
|
with \`sort -u \$tmp > \$SEL\`, a redirect that truncates \$SEL before sort
|
|
emits a byte. A SIGTERM, a full disk or a power cut there loses the
|
|
selection. cmd_expand next to it already writes tmp-then-mv. (BUG-8)"
|
|
fi
|
|
fi
|
|
|
|
# =============================================================== result ======
|
|
head_ "result"
|
|
if [ "$FAILS" -eq 0 ]; then
|
|
echo "PICKER PASS"
|
|
else
|
|
echo "PICKER: $FAILS failed assertion(s)"
|
|
fi
|
|
exit "$FAILS"
|