a001406a33
A disposable ubuntu container, a fake private tier and a fake bootstrap endpoint, so the whole documented path — chezmoi init --apply, dotup pick, dotup private, cmp apply, dotsecrets — can run end to end without touching a real machine or a real credential. The fake tier mirrors the real one's structure (seven secrets and one alias) because dotsecrets is copied verbatim and the "8 exports, not 7" assertion depends on that cardinality; its ids are sequential and obviously synthetic. check-verbatim.sh keeps the fake tier's copies of shipped files honest, and snapshot.sh records file modes so a 644 where a 600 belongs is a diff.
1057 lines
41 KiB
Bash
1057 lines
41 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-1/2 (^t over a filter ticks invasive rows that are not on
|
|
# screen, and is not its own undo), 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), BUG-7 (an unreadable state file makes every
|
|
# keystroke a silent no-op). Fixing those turns this green.
|
|
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
|
|
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- ;;
|
|
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] + 8000}]
|
|
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.
|
|
set n [llength [split $a "\n"]]
|
|
set c [api count]
|
|
if {[string is integer -strict $c] && $n != $c} {
|
|
bail "marks_settled: parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON"
|
|
}
|
|
return $a
|
|
}
|
|
}
|
|
bail "marks_settled: the picker never held still for 8s"
|
|
}
|
|
|
|
# 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 { foreach ch [split [lindex $rest 1] ""] { send -- $ch; after 80 }
|
|
after 500; 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
|
|
out=$(PATH=/tmp/nonet:$PATH "$D" pick 2>&1); rc=$?
|
|
[ "$rc" -eq 2 ] || fail "an unfetchable fzf exits $rc, not 2"
|
|
ok "dotup pick exits 2 when fzf cannot be had"
|
|
printf '%s\n' "$out" | sed 's/^/ | /'
|
|
case $out in
|
|
*"numbered prompt"*) fail "the error still promises a numbered prompt that does not exist" ;;
|
|
esac
|
|
case $out 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"
|
|
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"
|
|
selhas c2 core/tree || fail "space stopped working after junk in the state file"
|
|
ok "space still toggles with junk in the file (core/tree went on)"
|
|
[ "$(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"
|
|
cat > /tmp/steps.perm <<'EOS'
|
|
ready
|
|
snap perm1
|
|
at g:media
|
|
key space \x20
|
|
sleep 2500
|
|
snap perm2
|
|
abort
|
|
end
|
|
EOS
|
|
if drive perm 44 220 /tmp/steps.perm "$D pick"; then
|
|
if [ "$(markof perm1 g:core)" = "." ]; then
|
|
note "an unreadable selected file draws as 'nothing is ticked' rather than as
|
|
an error — the awk that reads it cannot tell 'empty' from 'refused'"
|
|
fi
|
|
if diff -q /tmp/snap.perm1 /tmp/snap.perm2 >/dev/null; then
|
|
fail "with an unreadable state file, space does nothing at all and says nothing:
|
|
cmd_toggle's final \`sort -u \$tmp > \$SEL\` fails, execute-silent throws
|
|
the status away, and the row never moves. The picker looks alive and is
|
|
inert. (BUG-7)"
|
|
else
|
|
ok "a toggle still lands with an unreadable state file"
|
|
fi
|
|
fi
|
|
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"
|