#!/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. # # What is left is DEFERRED, not unknown, and there is no expected-fail # mechanism here to hide it behind: BUG-1/2, in `promise 3`, three assertions. # ^t over an --exact filter widens along @needs and ticks invasive rows that # are not on screen, and ^t ^t is not its own undo because the reverse edges # do not retract what the forward ones pulled in. Until those are fixed this # scenario exits 3. 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 keys every VISIBLE row's key, in screen order # api.sh marks "" per visible row; mark is x, ~ or . # api.sh count matchCount (rows the filter is showing) # api.sh total totalCount # api.sh pos cursor position, 0-based # api.sh 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 # # 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 "" 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 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"