Files
dotfiles-public/.tests/lab/scenarios/20-picker.sh
T
bcherb2 7b28415cc6 test: refresh three picker assertions against the fixed behaviour
1ea6b49 changed what the picker does on purpose. Three assertions here were
still holding the old behaviour and failing for it; none of them were finding
a bug. The product is untouched.

- hostile 3 stacked two faults, no terminal AND no fetchable fzf, and asserted
  the fzf message. The /dev/tty probe was deliberately moved ahead of
  ensure_fzf (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 now unreachable. Split in two: the no-tty arm asserts the ORDER,
  and a second arm runs the same missing fzf under a pty, via `script -e`,
  which is the only way to reach ensure_fzf's error at all now. Both arms
  still exit 2, and the fallback it names is still resolved for real.

- hostile 4's hand-edited state file was not being read: seeding the defaults
  preset is now decided by $STATE/picked ("a pick has completed") rather than
  by "$SEL is empty", so the fixture's junk was overwritten with 43 default
  rows before anything drew, and the session was testing the preset. core/tree
  is a default, so the space under test unticked it and the assertion read
  "space stopped working". The fixture now records a completed pick, as every
  machine that has pressed enter does. The CRLF row consequently reports its
  real result — silently ignored — where before it falsely read as tolerated,
  and the pass that followed the failure is no longer printed unconditionally.

- the `perm` session drove a picker that no longer draws. $SEL being readable
  and writable is now a precondition of drawing, because every bind is
  execute-silent and throws its child's status away (that was BUG-7). There is
  no session left to drive: the assertion is that nothing draws, that it exits
  2, and that the refusal names the file and a way out. Through a pty again,
  since the tty probe would otherwise answer first.

Deferred, and still failing: BUG-1/BUG-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. There is no expected-fail
mechanism in this harness and none was invented, so the scenario exits 3 and
the header says why.

Verified: two consecutive runs of `run.sh scenarios/20-picker.sh` at exit 3
with exactly those three failures, and `scenarios/00-smoke.sh` still at 0.
2026-08-21 23:13:40 -04:00

1096 lines
44 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.
#
# 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 <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
# 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"