Files
dotfiles-public/.tests/test.sh
T
bcherb2 f30dddce15 fix: resolve chezmoi instead of assuming it is on PATH
The private tier died with `chezmoi: not found` on a real install, AFTER the
bws token had been written -- half-configured, at the last step, password
already spent.

dotup arrives VIA chezmoi, so "it must be here already" is the natural
assumption. It is wrong: get.chezmoi.io installs to ./bin relative to the CWD
when -b is not given, which is exactly what the README's one-liner does. Run it
from $HOME and the binary is ~/bin/chezmoi; run it from /workspace, as anyone
in a container does, and it is /workspace/bin/chezmoi. Neither is on PATH, and
~/.local/bin is not either in bash -- the same gap that makes bare `dotup` fail
on a fresh box.

find_tool exists for precisely this and the call site bypassed it.

  - find_tool now also searches $HOME/bin
  - ensure_chezmoi resolves it, and installs to ~/.local/bin only if it truly
    is absent, mirroring ensure_bws
  - the init uses "$CHEZMOI", never the bare name
  - resolution happens BEFORE the credential prompt, so this fails while it is
    still free rather than after the password is spent

Verified by reproducing the exact scenario: installer run from /workspace, so
chezmoi lands in /workspace/bin and nothing on PATH can see it. Old code:
`chezmoi: not found`. New: detected, installed, prompt reached. Also confirmed
~/bin/chezmoi is found WITHOUT re-downloading, so the common case costs nothing.

113 -> 117.
2026-08-17 23:09:03 -04:00

480 lines
26 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.
# Indentation-agnostic on purpose: these two lines moved from function body to
# inside the retry loop, and an anchor of `^\t` silently stopped matching, so
# the eval became a no-op and the assertions failed as if dotup were broken.
# Bind to the statement, not to where it happens to sit.
norm() { P_URL=$1; eval "$(sed -n 's/^[[:space:]]*\(P_URL=\${P_URL%[^}]*}\)$/\1/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[1mchezmoi is not assumed to be on PATH\033[0m\n'
# dotup ARRIVES via chezmoi, so "it must be here already" is the natural
# assumption and it is wrong: get.chezmoi.io installs to ./bin relative to the
# CWD when -b is not given, which is what the README one-liner does. From
# /workspace that is /workspace/bin, on no PATH anywhere. The private tier died
# with `chezmoi: not found` AFTER writing the bws token.
is "nothing calls chezmoi by bare name" "0" \
"$(grep -cE '^[[:space:]]*chezmoi (init|apply|update)' $D)"
is "the init uses the resolved path" "1" \
"$(grep -cF '"$CHEZMOI" init --apply' $D)"
is "find_tool looks in ~/bin too" "1" \
"$(grep -cF '"$HOME/bin/$1"' $D)"
# Resolution happens before the prompt: discovering it afterwards means the
# password is spent and the token is already on disk.
is "chezmoi is resolved before the password is asked for" "yes" \
"$(awk '/ensure_chezmoi \|\|/{e=NR} /head_ "private tier"/{h=NR} END{print (e && h && e<h) ? "yes" : "no"}' $D)"
printf '\n\033[1ma wrong password is not a reinstall\033[0m\n'
# The endpoint credentials are asked for at the very END of a run, after every
# package is installed. Any non-200 used to be fatal, so one mistyped character
# meant repeating the whole install to get back to the prompt.
is "the prompt retries instead of returning" "0" \
"$(grep -c 'endpoint refused the credentials' $D)"
is "…up to a bounded number of attempts" "1" \
"$(grep -c '"$p_try" -gt 5' $D)"
is "…and points at the cheap way back in" "1" \
"$(grep -c 'Re-run only this step: dotup private' $D)"
# One message for 401, 404 and an unreachable host is how a URL-shape bug spends
# an evening looking like a password problem. Each needs a different next move.
is "a 401 names the password" "1" "$(grep -c 'wrong username or password' $D)"
is "a 404 names the route" "1" "$(grep -c 'no bootstrap.env is there' $D)"
is "an unreachable host says so" "1" "$(grep -c 'could not reach that address' $D)"
# --fail is gone, so the code must be checked explicitly or an error page would
# be parsed as the credential blob.
is "the blob is used only on 200" "1" "$(grep -cF '200) break ;;' $D)"
# The subcommand existed but was absent from --help, so there was no way to
# discover the recovery path.
is "dotup private is documented" "1" \
"$(sh $D --help 2>&1 | grep -c 'ONLY the private tier')"
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 ]