Files
dotfiles-public/.tests/test.sh
T
bcherb2 2afdfd093b fix: accept either bootstrap URL shape, in dotup and in the harness
Three files each appended /bootstrap.env to a value whose shape nobody had
pinned down, and they did not agree:

  - the rotation scripts print the FULL file URL and say to store THAT in
    Bitwarden, so that is what gets pasted
  - dotup asked for the directory and appended /bootstrap.env itself
  - .tests/e2e.sh independently duplicated dotup's assumption

Pasting the saved value made the request .../bootstrap.env/bootstrap.env. The
404 tripped curl --fail and surfaced as "endpoint refused the credentials. The
machine stays public-only" -- blaming the password for a URL shape, which is
about the most expensive wrong error message this path could produce.

dotup and the wrapper now both strip a trailing slash and a trailing
/bootstrap.env before building the request, so either form works and the
existing Bitwarden entry needs no editing.

Four assertions cover all four shapes -- directory and file URL, each with and
without a trailing slash. They eval the normalisation lifted straight out of
dotup rather than a copy, and that binding was mutation-tested: deleting the
line from dotup makes them fail with exactly the production symptom,
https://h/r/bootstrap.env/bootstrap.env. 101 -> 105.

The second instance, in e2e.sh, was found only by running the harness with the
file URL instead of the directory. Every earlier run passed because it was fed
the shape that happened to be in a variable, not the shape a person has in a
password manager. Both shapes now run end to end and pass.

The normalisation exists in two places because the harness cannot source
dotup's internals. That is the same duplication that caused this, and only
dotup's copy is covered by the suite.
2026-08-17 22:52:11 -04:00

436 lines
23 KiB
Bash
Executable File

#!/bin/sh
# dotup test suite.
#
# Everything here is pty-free and runs unchanged in a container: the toggle
# rule is pure state-file logic, the match-confinement test uses
# `fzf --filter`, which is non-interactive, and the installer is driven against
# a directory of fake package managers that record what they were asked to do
# and install nothing. The one test that genuinely needs a terminal is fenced
# off at the bottom and skips cleanly without one.
#
# sh .tests/test.sh on the host
# sh .tests/test.sh --docker in a clean ubuntu:24.04 container
#
# NOTHING IN THIS FILE INSTALLS A PACKAGE. If you add a case that could, it
# belongs in a container and behind an explicit opt-in, not here.
set -eu
cd "$(dirname "$0")"
if [ "${1:-}" = "--docker" ]; then
# curl and script are here so the pty test runs too: the container has
# apt's fzf 0.44.1, not whatever the dev box has, and the cursor-on-reload
# behaviour is the one property that must hold on the older release.
exec docker run --rm -v "$(cd .. && pwd):/w:ro" "${DOTUP_TEST_IMAGE:-ubuntu:24.04}" sh -c '
apt-get update -qq
apt-get install -y -qq fzf curl ca-certificates >/dev/null 2>&1
cp -r /w /work && cd /work/.tests && rm -rf state fzfver
echo " distro fzf: $(fzf --version 2>/dev/null || echo none)"
sh ../dot_local/bin/executable_dotup preflight # exactly what a real run does first
exec sh test.sh'
fi
ROOT=$(CDPATH= cd -- .. && pwd)
D=$ROOT/dot_local/bin/executable_dotup
M=$ROOT/dot_local/share/dotup/packages.tsv
export DOTUP_STATE=$PWD/state
export DOTUP_MANIFEST=$M
pass=0; fail=0
ok() { pass=$((pass+1)); printf ' \033[32mok\033[0m %s\n' "$1"; }
no() { fail=$((fail+1)); printf ' \033[31mFAIL\033[0m %s\n' "$1"; }
is() { # is <desc> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else no "$1 — want [$2] got [$3]"; fi
}
has() { # has <desc> <needle> <haystack>
case $3 in *"$2"*) ok "$1" ;; *) no "$1 — [$2] not in output" ;; esac
}
hasnt(){ # hasnt <desc> <needle> <haystack>
case $3 in *"$2"*) no "$1 — [$2] IS in output" ;; *) ok "$1" ;; esac
}
# Normalise before parsing: "[x]" is one awk field but "[ ]" is two, so column
# positions shift with tick state. Collapse the box to a single token first.
rows() { sh $D render | sed 's/\x1b\[[0-9;]*m//g; s/\[[x~ ]\]/BOX/'; }
grp() { rows | awk -v g="$1" '$1!="BOX" && $3==g {print $4}'; }
# Every selected key matching a flag. Trailing ":" keeps a failed grep on the
# last item from tripping set -e via the loop's exit status.
picked(){ awk -F'\t' -v f="$1" '!/^#/ && !/^@/ && NF>=3 && $3==f {print $1"/"$2}' "$M" \
| while read -r k; do grep -qxF "$k" state/selected && echo "$k"; done; :; }
missed(){ awk -F'\t' -v f="$1" '!/^#/ && !/^@/ && NF>=3 && $3==f {print $1"/"$2}' "$M" \
| while read -r k; do grep -qxF "$k" state/selected || echo "$k"; done; :; }
reset() { rm -rf state; sh $D preset defaults; }
printf '\n\033[1mtoggle rule\033[0m\n'
reset
is "package off -> group goes partial" "2/3" \
"$(sh $D toggle p:media/sox >/dev/null; grp media)"
is "group partial -> all on" "3/3" \
"$(sh $D toggle g:media >/dev/null; grp media)"
is "group full -> all off" "0/3" \
"$(sh $D toggle g:media >/dev/null; grp media)"
is "group empty -> all on" "3/3" \
"$(sh $D toggle g:media >/dev/null; grp media)"
is "bulk, mixed set -> all on" "3/3" \
"$(sh $D toggle p:gpu/cuda-toolkit >/dev/null
sh $D toggle p:gpu/nvidia-driver p:gpu/cuda-toolkit p:gpu/container-toolkit >/dev/null
grp gpu)"
is "bulk, all on -> all off" "0/3" \
"$(sh $D toggle p:gpu/nvidia-driver p:gpu/cuda-toolkit p:gpu/container-toolkit >/dev/null
grp gpu)"
printf '\n\033[1mdependencies — @needs auto-ticks, both directions\033[0m\n'
on() { grep -qxF "$1" state/selected && echo on || echo off; }
reset; sh $D toggle p:networking/xrdp >/dev/null
is "ticking xrdp pulls in the desktop group" "2/2" "$(grp desktop)"
sh $D toggle g:desktop >/dev/null
is "unticking desktop drops xrdp" "off" "$(on networking/xrdp)"
is "unticking desktop drops xorgxrdp" "off" "$(on networking/xorgxrdp)"
reset; sh $D toggle p:gpu/container-toolkit >/dev/null
is "container-toolkit pulls in docker" "3/3" "$(grp docker)"
reset; sh $D toggle p:core/node >/dev/null
is "dropping node drops codex" "off" "$(on agents/codex)"
is "dropping node drops mermaid-cli" "off" "$(on core/mermaid-cli)"
is "…and transitively drops neovim" "off" "$(on core/neovim)"
is "but leaves unrelated packages be" "on" "$(on core/ripgrep)"
# The directives must not leak into package parsing.
is "@needs lines are not packages" "" \
"$(rows | awk '$1!="BOX" && $3 ~ /@/ {print $3}')"
is "group count unchanged by directives" "11" \
"$(awk -F'\t' '!/^[#@]/ && NF>=3 {print $1}' "$M" | uniq | grep -c .)"
# @spec is the second directive kind and must be as invisible as the first.
is "@spec lines are not packages" "0" \
"$(sh $D render | cut -f2 | grep -c 'spec' || true)"
reset; sh $D toggle p:core/uv >/dev/null
is "dropping uv drops specify-cli" "off" "$(on agents/specify-cli)"
# bws-secrets promised seven API keys and installed nothing to fetch them with.
# The binary ships as a .zip and 24.04 minimal has no unzip, so the dependency
# is real rather than decorative.
reset; sh $D toggle p:private/bws-secrets >/dev/null
is "ticking bws-secrets pulls in unzip" "on" "$(on core/unzip)"
# bws must NOT be a manifest package. selected_packages drops every private row
# ("private is never a package" is what makes --unattended safe), and a safe row
# would put a Bitwarden binary on every throwaway public VM via the defaults
# preset. So the private tier installs it itself, next to the token.
is "bws-secrets stays a non-package" "-" \
"$(awk -F'\t' '$1=="private" && $2=="bws-secrets" {print $4}' "$M")"
is "no manifest row installs bws" "0" \
"$(awk -F'\t' '!/^[#@]/ && NF>=3 && $2=="bws" {c++} END{print c+0}' "$M")"
is "the private tier installs bws itself" "1" \
"$(grep -c '^ ensure_bws$' $D)"
is "…only after a token exists to use" "1" \
"$(grep -A1 'bws token written, mode 600' $D | grep -c ensure_bws)"
is "bws is checksum-verified" "1" \
"$(grep -c 'bws checksum mismatch' $D)"
printf '\n\033[1mcredentials never reach a log\033[0m\n'
# `run` echoes its whole argv to stderr. The private init's argv ends in
# https://user:TOKEN@host, so it must not go through `run`.
is "private init does not use run()" "0" \
"$(grep -c 'run chezmoi init' $D)"
# Two, not one: the dry-run branch traces it too, and a dry run that printed a
# live token would be the worse leak of the pair.
is "both traced URLs are redacted" "2" \
"$(grep -c 'redact_url "\$p_clean"' $D)"
# Not printing the token was never enough. p_repo is the URL as the endpoint
# hands it over, credential inline; putting THAT on chezmoi's command line
# publishes it to /proc/<pid>/cmdline, which every account on the box can read
# for as long as the clone runs, and then into the clone's .git/config, which
# keeps it. Only the split-out p_clean may reach an argv.
is "the credential-bearing URL never reaches an argv" "0" \
"$(grep -c 'chezmoi init.*\$p_repo' $D)"
is "the token goes to a credential file instead" "1" \
"$(grep -c "umask 077; printf '%s\\\\n' \"\\\$p_cred\" > \"\\\$PRIV_CRED\"" $D)"
is "…at mode 600" "1" \
"$(grep -c 'chmod 600 "\$PRIV_CRED"' $D)"
# Exercise the real implementation lifted straight out of dotup. A copy of the
# sed expression here would keep passing after someone edited the original.
split() { p_repo=$1; eval "$(sed -n '/p_cred=\$(printf/p;/p_clean=\$(printf/p' $D)"
printf '%s %s\n' "${p_cred:-<none>}" "$p_clean"; }
is "split lifts the credential out of the URL" \
"https://ben:deadbeefcafe@git.example.com https://git.example.com/x.git" \
"$(split 'https://ben:deadbeefcafe@git.example.com/x.git')"
# A port must survive into the credential line: git matches the store entry on
# host AND port, so dropping :3000 would silently stop authenticating.
is "…keeping the port" \
"http://u:p@example.com:3000 http://example.com:3000/a/b.git" \
"$(split 'http://u:p@example.com:3000/a/b.git')"
# No userinfo means nothing to store and nothing to strip — ssh remotes and
# unauthenticated https must pass through byte-identical.
is "…and leaves a credential-free remote untouched" \
"<none> https://git.example.com/x.git" \
"$(split 'https://git.example.com/x.git')"
is "…including scp-style ssh" \
"<none> git@git.example.com:ben/x.git" \
"$(split 'git@git.example.com:ben/x.git')"
# The bootstrap URL a human pastes comes from Bitwarden, where the rotation
# scripts put the FULL file URL. dotup appends /bootstrap.env itself, so without
# normalisation that becomes .../bootstrap.env/bootstrap.env -- a 404 that
# curl --fail turns into "endpoint refused the credentials", blaming the
# password for a URL shape. Both forms must land on the same request.
norm() { P_URL=$1; eval "$(sed -n '/^\tP_URL=\${P_URL%\//p;/^\tP_URL=\${P_URL%\/bootstrap.env}/p' $D)"
printf '%s\n' "${P_URL%/}/bootstrap.env"; }
is "a directory URL resolves to the blob" "https://h/r/bootstrap.env" \
"$(norm 'https://h/r/')"
is "...without a trailing slash too" "https://h/r/bootstrap.env" \
"$(norm 'https://h/r')"
is "...and the full file URL, which is what Bitwarden holds" "https://h/r/bootstrap.env" \
"$(norm 'https://h/r/bootstrap.env')"
is "...even with a trailing slash on the file URL" "https://h/r/bootstrap.env" \
"$(norm 'https://h/r/bootstrap.env/')"
rd() { eval "$(sed -n '/^redact_url()/p' $D)"; redact_url "$1"; }
is "redact_url strips userinfo" "https://<redacted>@git.example.com/x.git" \
"$(rd 'https://ben:deadbeefcafe@git.example.com/x.git')"
is "...and leaves a credential-free URL alone" "https://git.example.com/x.git" \
"$(rd 'https://git.example.com/x.git')"
# Two tiers, two configs. Sharing one meant the public installer silently ate
# the private tier's seven promptStringOnce answers.
is "private init carries its own -c" "1" \
"$(grep -c '\-c "\$PRIV_CFG"' $D)"
is "PRIV_CFG is not the default config path" "0" \
"$(grep -c 'PRIV_CFG=.*chezmoi/chezmoi.toml' $D)"
is "the private source is locked down after clone" "1" \
"$(grep -c 'chmod -R go-rwx "\$PRIV_SRC"' $D)"
printf '\n\033[1mrisk model — the invariants that matter\033[0m\n'
reset
is "defaults tick nothing invasive" "" "$(picked invasive)"
is "defaults tick nothing private" "" "$(picked private)"
is "defaults tick every safe package" "" "$(missed safe)"
# A group must never render a flag milder than something inside it.
bad=$(rows | awk '$1!="BOX" && $2=="BOX" {print $3, $5}' \
| while read -r g f; do
worst=$(awk -F'\t' -v g="$g" '!/^#/ && !/^@/ && NF>=3 && $1==g {
s=($3=="invasive")?3:($3=="private")?2:($3=="gui")?1:0
if (s>m) m=s } END{ print (m==3)?"invasive":(m==2)?"private":(m==1)?"gui":"safe" }' "$M")
[ "$f" = "$worst" ] || echo "$g shows $f, worst child is $worst"
done; :)
is "no group looks safer than its contents" "" "$bad"
printf '\n\033[1mmatch confinement — regression for the ^t bug\033[0m\n'
if command -v fzf >/dev/null; then
sh $D expand-all >/dev/null # every package row visible
grep -q gpu/nvidia-driver <<-EOF || sh $D expand-all >/dev/null
$(sh $D render | cut -f2)
EOF
rows=$(sh $D render | sed 's/\x1b\[[0-9;]*m//g')
hit=$(printf '%s\n' "$rows" | fzf --exact --filter=nvidia --no-sort | cut -f2 | sort)
is "--exact: 'nvidia' hits only gpu packages" \
"p:gpu/container-toolkit
p:gpu/cuda-toolkit
p:gpu/nvidia-driver" "$hit"
# The bug: without --exact this also matches docker, networking and desktop,
# and ^t silently switched all three on.
loose=$(printf '%s\n' "$rows" | fzf --filter=nvidia --no-sort | cut -f2 \
| grep -cv '^p:gpu/' || true)
if [ "$loose" -gt 0 ]; then
ok "fuzzy would over-match $loose non-gpu rows (why --exact is required)"
else
no "fuzzy no longer over-matches — re-check whether --exact is still load-bearing"
fi
is "the fzf invocation carries --exact" "1" \
"$(grep -c '\--ansi --exact' $D)"
# Every action the picker binds must exist on THIS fzf. Ubuntu 24.04 ships
# 0.44.1; a dev box may be 28 releases ahead. fzf validates binds at
# startup, and empty stdin makes it exit without needing a terminal.
badbind=$(sed -n 's/.*--bind "\([^"]*\)".*/\1/p; s/.*--bind '"'"'\([^'"'"']*\)'"'"'.*/\1/p' $D \
| sed "s|\$SELF|$D|g" \
| while read -r b; do
out=$(fzf --bind "$b" </dev/null 2>&1 || true)
case $out in *nvalid*|*nknown*|*nsupported*) echo "$b" ;; esac
done; :)
is "every binding parses on fzf $(fzf --version | cut -d' ' -f1)" "" "$badbind"
else
printf ' \033[33mskip\033[0m fzf not installed\n'
fi
printf '\n\033[1mpreflight — the picker brings its own fzf\033[0m\n'
vc() { sh $D vercmp "$1" "$2" && echo ge || echo lt; }
is "0.44.1 clears the floor" "ge" "$(vc 0.44.1 0.44.0)"
is "0.29 (jammy) does not" "lt" "$(vc 0.29 0.44.0)"
is "0.38 (bookworm) does not" "lt" "$(vc 0.38.0 0.44.0)"
is "compare is numeric, not lexical" "ge" "$(vc 0.100.0 0.44.0)"
is "0.9.5 is not above 0.44.0" "lt" "$(vc 0.9.5 0.44.0)"
is "preflight resolves a fzf above the floor" "ge" \
"$(vc "$(sh $D fzf-path | xargs -r -I{} sh -c '{} --version' | awk '{print $1}')" 0.44.0)"
# The picker must call its resolved binary, never whatever PATH happens to hold.
is "picker invokes the resolved binary" "1" \
"$(grep -c 'cmd_render | "\$FZF"' $D)"
is "picker never calls bare fzf" "0" \
"$(grep -c 'render | fzf ' $D)"
# The whole point of the cache: PATH and ~/.local/bin are left alone.
is "preflight writes nothing to ~/.local/bin" "absent" \
"$(sh $D preflight >/dev/null 2>&1; [ -e "$HOME/.local/bin/fzf" ] && echo present || echo absent)"
is "preflight does not export PATH" "0" \
"$(grep -c 'PATH=.*export PATH' $D)"
# fzf is still offered as a normal package — for your shell, not for the picker.
is "manifest still offers fzf to you" "core/fzf" \
"$(awk -F'\t' '!/^#/ && !/^@/ && NF>=3 && $2=="fzf" {print $1"/"$2}' "$M")"
printf '\n\033[1mplatform resolution\033[0m\n'
is "brew-only pkg resolves on linux (omp)" "" \
"$(sh $D expand g:agents >/dev/null
sh $D render | sed 's/\x1b\[[0-9;]*m//g' | grep 'omp' | grep -o 'unavailable' || true)"
# One place decides what installs a package; everything downstream reads it.
rs() { sh $D resolve "$1" | tr '\t' ' '; }
is "plain apt name resolves to apt" "apt sox" "$(rs media/sox)"
is "no apt package falls through to brew" "brew lazygit" "$(rs core/lazygit)"
is "tap-only pkg falls through to brew" "brew can1357/tap/omp" "$(rs agents/omp)"
is "special channel carries its @spec" "npm @openai/codex" "$(rs agents/codex)"
is "…and the scoped mermaid name" "npm @mermaid-js/mermaid-cli" "$(rs core/mermaid-cli)"
is "deb channel carries a source" "deb gh:mkasberg/ghostty-ubuntu:_amd64.deb" "$(rs apps/ghostty)"
is "snap channel renames to the binary" "snap bw" "$(rs core/bitwarden-cli)"
is "tarball is dispatched, not named" "tarball neovim" "$(rs core/neovim)"
is "an unknown key says so" "missing" "$(rs nope/nope | tr -d ' ')"
# The macOS half of the manifest is exercised nowhere else. Resolution is pure
# data, so a fake `uname` is enough to check it — and it caught a real bug: the
# apt/brew fallback used to run both ways, so a Linux-only package resolved to
# `apt install davfs2` on a machine that has never had apt.
DARWIN=$PWD/fakeuname
drs() { PATH="$DARWIN:$PATH" sh $D resolve "$1" | tr '\t' ' '; }
is "darwin: tap-only pkg is a brew tap" "brew can1357/tap/omp" "$(drs agents/omp)"
is "darwin: zsh is built in" "builtin zsh" "$(drs core/zsh)"
is "darwin: no apt package is not a reason to try apt" "unavailable" \
"$(drs networking/davfs2 | tr -d ' ')"
# Nothing in the default set may be unresolvable — that is a manifest bug, and
# it is silent until someone runs the installer on a fresh machine.
reset
is "every default package resolves" "" \
"$(sh $D plan 2>/dev/null | sed -n '/no source on this platform/,$p' | grep -v 'no source' || true)"
printf '\n\033[1mthe installer — driven against fake package managers\033[0m\n'
FAKE=$PWD/fakebin
export DOTUP_TEST_LOG=$PWD/state/calls.log
# --print resolves everything and must call nothing at all. The fakes are first
# on PATH, so any call whatsoever leaves a trace.
reset
: > "$DOTUP_TEST_LOG"
out=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>&1 || true)
is "--print calls no package manager" "0" "$(grep -c . "$DOTUP_TEST_LOG" || true)"
has "--print still shows the apt batch" "apt-get install -y" "$out"
has "--print shows the npm batch" "npm install -g" "$out"
hasnt "--print never reaches the private stage" "Bootstrap URL:" "$out"
# Now the real engine, over a subset chosen to exercise every non-destructive
# channel. Deliberately not the whole default set: the tarball and script
# handlers write outside $HOME, and a test suite has no business doing that.
reset
sh $D preset none
cat > state/selected <<'EOF'
agents/omp
agents/specify-cli
apps/obsidian
core/bitwarden-cli
core/gh
core/lazygit
core/mermaid-cli
core/ripgrep
EOF
: > "$DOTUP_TEST_LOG"
PATH="$FAKE:$PATH" sh $D install >/dev/null 2>&1 || true
log=$(cat "$DOTUP_TEST_LOG")
has "apt batches its packages in one call" "apt-get install -y ripgrep" "$log"
has "brew gets the tap-only package" "brew install can1357/tap/omp" "$log"
has "brew gets the apt-less package" "brew install lazygit" "$log"
has "npm gets the scoped spec" "@mermaid-js/mermaid-cli" "$log"
has "uv installs the tool, not the package" "uv tool install specify-cli" "$log"
has "snap gets the renamed binary" "snap install bw" "$log"
has "flatpak gets the app id" "flatpak install" "$log"
has "…with the real obsidian app id" "md.obsidian.Obsidian" "$log"
# The one that matters on Linux: apt not knowing a name is not a dead end.
has "apt probes before it batches" "apt-cache show gh" "$log"
has "an apt name apt rejects moves to brew" "brew install gh" "$log"
hasnt "…and is not left in the apt batch" "apt-get install -y gh" "$log"
# npm cannot run before node, uv cannot run before uv. The pipeline is fixed
# rather than sorted, so assert the order it actually produces.
is "apt runs before npm" "yes" \
"$(a=$(grep -n 'apt-get install' "$DOTUP_TEST_LOG" | head -1 | cut -d: -f1)
b=$(grep -n 'npm install' "$DOTUP_TEST_LOG" | head -1 | cut -d: -f1)
[ -n "$a" ] && [ -n "$b" ] && [ "$a" -lt "$b" ] && echo yes || echo no)"
# Nothing runs as root that does not have to.
hasnt "brew is never run through sudo" "sudo brew" "$log"
printf '\n\033[1munattended — the boundary holds because of what is missing\033[0m\n'
# A stale state file is the adversary here: it ticks the two things an
# unattended run must never act on, and the run has to refuse both anyway.
rm -rf state; mkdir -p state
cat > state/selected <<'EOF'
core/ripgrep
docker/docker-ce
networking/openssh-server
private/bws-secrets
private/private-repo
EOF
: > "$DOTUP_TEST_LOG"
out=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>&1 || true)
hasnt "unattended never installs a private row" "bws-secrets" "$out"
hasnt "unattended never prompts for a password" "Password:" "$out"
hasnt "unattended refuses a stale invasive tick" "docker-ce" "$out"
hasnt "…including a listening ssh port" "openssh-server" "$out"
has "…but still installs the safe defaults" "ripgrep" "$out"
# Determinism: the same command twice, on the same machine, means the same
# thing. A state file left by an interactive run must not change it.
a=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>/dev/null || true)
sh $D preset none
b=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>/dev/null || true)
is "unattended is computed, not inherited" "same" \
"$([ "$a" = "$b" ] && echo same || echo different)"
# `set -e` would kill the subshell at the failing command, so the status is
# captured through a || branch rather than read from $? afterwards.
rc() { c=0; "$@" >/dev/null 2>&1 || c=$?; echo "$c"; }
is "unattended exits clean when nothing fails" "0" \
"$(PATH="$FAKE:$PATH" rc sh $D --unattended --print)"
is "an unknown flag is refused" "2" "$(rc sh $D --nonsense)"
# The negative test in PLAN.md phase 3 runs `zsh -ic exit` after an unattended
# run. That only passes if zsh is in the unattended set.
has "unattended installs the shell it configures" "zsh" \
"$(reset; PATH="$FAKE:$PATH" sh $D --unattended --print 2>&1 | grep 'apt-get install' || true)"
printf '\n\033[1mruns where it has to run\033[0m\n'
# A machine with no sudo and no root is a real case -- a locked-down work box, a
# rootless container. `A || { B && C; }` is one || list, so when `command -v
# sudo` failed the whole list failed and `set -e` killed dotup at load, before
# it printed anything. A minimal PATH is the only way to see that.
# Not under state/ -- reset() removes that whole directory, and a PATH that
# vanishes mid-test looks exactly like the bug being tested for.
MIN=${TMPDIR:-/tmp}/dotup-minbin.$$
rm -rf "$MIN"; mkdir -p "$MIN"
# Resolved by looking, not by `command -v`: an interactive shell can report an
# alias or a bare name for these, and a symlink to a bare name is a loop.
for t in awk grep sed sort cut tr id uname mkdir cp mv rm cat head sh dirname basename; do
for bd in /usr/bin /bin /usr/local/bin; do
[ -x "$bd/$t" ] && { ln -sf "$bd/$t" "$MIN/$t"; break; }
done
done
reset >/dev/null 2>&1
is "runs as a non-root user with no sudo on PATH" "0" \
"$(c=0; env -i HOME="$HOME" PATH="$MIN" DOTUP_STATE="$DOTUP_STATE" DOTUP_MANIFEST="$M" \
"$MIN/sh" $D --unattended --print >/dev/null 2>&1 || c=$?
echo "$c")"
is "…and still resolves the whole default set" "1" \
"$(env -i HOME="$HOME" PATH="$MIN" DOTUP_STATE="$DOTUP_STATE" DOTUP_MANIFEST="$M" \
"$MIN/sh" $D --unattended --print 2>/dev/null | grep -c 'apt-get install -y ')"
rm -rf "$MIN"
# `sudo` must not be glued onto anything that is not a system package manager.
is "sudo is only ever used for apt, snap and dpkg" "" \
"$(grep -oE '\$\{SUDO:\+\$SUDO \}[a-z-]+' $D | sed 's/.*}//' | sort -u \
| grep -vE '^(apt-get|snap|rm|mkdir|tar|ln)$' || true)"
printf '\n\033[1minteractive loop (needs a pty)\033[0m\n'
if command -v fzf >/dev/null && command -v curl >/dev/null && command -v script >/dev/null; then
out=$(sh ./listen-test.sh 2>/dev/null | tr -d '\r' || true)
case $out in
*CURSOR_HELD*) ok "cursor survives toggle+reload" ;;
*) no "cursor position lost across reload — rapid ticking breaks" ;;
esac
else
printf ' \033[33mskip\033[0m needs fzf, curl and script\n'
fi
printf '\n%d passed, %d failed\n\n' "$pass" "$fail"
[ "$fail" -eq 0 ]