#!/usr/bin/env bash
# Snapshot bracket for bin/setup's post-prelude phases: run bin/setup in a
# hermetic sandbox where every effectful command is a recorder stub, and
# compare the ordered command sequence and step output against goldens in
# test/fixtures/setup_phases/. The commands goldens are the contract — any
# refactor of how bin/setup dispatches its phases must leave them
# byte-identical, including the exit trailer. Output goldens pin the step
# labels; only the dim command-echo lines may change across a dispatch
# refactor.
#
# The sandbox stubs bin/rails, bin/minio-setup, docker, nc, brew, bundle, gh,
# gum, and ~/Work/basecamp/docker-dev/setup (under a fake HOME). mise is a
# dispatcher: run/tasks/trust exec the REAL mise against the sandbox's own
# TOML (whose task leaves are the recorders), with --skip-tools injected so
# nothing downloads; install/bootstrap/self-update no-op. Requires a real
# mise >= the repo floor on PATH — the same thing bin/setup's prelude
# guarantees on any dev machine.
#
# Invocation (from anywhere):
#
# test/setup-phases-test # compare against goldens
# REGEN=1 test/setup-phases-test # regenerate goldens
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FIXTURES="$ROOT/test/fixtures/setup_phases"
REAL_MISE="$(command -v mise)" || { echo "error: setup-phases-test needs mise on PATH" >&2; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/setup-phases.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
# Physical path: bin/setup canonicalizes app_root with pwd, and recorded
# paths must match it for $APP normalization (macOS /var -> /private/var).
WORK="$(cd "$WORK" && pwd -P)"
pass=0 fail=0
ok() { pass=$((pass+1)); echo " ok: $1"; }
bad() { fail=$((fail+1)); echo " FAIL: $1"; }
build_sandbox() { # build_sandbox
local sb="$1" stub
mkdir -p "$sb/bin" "$sb/saas/bin" "$sb/tmp" "$sb/tmpdir" "$sb/stubbin" \
"$sb/home/Work/basecamp/docker-dev" "$sb/home/.config" \
"$sb/home/.local/share" "$sb/home/.local/state" "$sb/home/.cache"
cp "$ROOT/bin/setup" "$sb/bin/setup"
cp "$ROOT/saas/bin/setup" "$sb/saas/bin/setup"
cp "$ROOT/.mise.toml" "$sb/.mise.toml"
cp "$ROOT/mise.saas.toml" "$sb/mise.saas.toml"
cp "$ROOT/Brewfile" "$sb/Brewfile"
: > "$sb/home/global-mise.toml"
# The sandbox must be a git repo: bin/setup's pre-commit hooks step runs
# `git config core.hooksPath`, which needs one (and writes only sandbox-local
# config). The docker-dev fenced-block probes stay unaffected — the sandbox
# toplevel never equals the docker-dev path, so they skip as before. Init
# hermetically: host git config (init.templateDir, hooks) must not leak in.
env HOME="$sb/home" GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
git -C "$sb" init -q
# Recorders append one normalized line per invocation. STUB_FAIL names a
# recorded-command prefix that should exit 9 AFTER recording, so the stop
# point stays visible in commands.txt.
cat > "$sb/stubbin/_stublib" <<'EOF'
rec() {
local line="$1"
line="${line//$STUB_SANDBOX\/home/\$HOME}"
line="${line//$STUB_SANDBOX/\$APP}"
printf '%s\n' "$line" >> "$STUB_RECORD"
}
die_if_fail() {
case "$1" in "${STUB_FAIL:-__none__}"*) echo "stub-fail: $1" >&2; exit 9;; esac
}
EOF
cat > "$sb/bin/rails" <<'EOF'
#!/usr/bin/env bash
source "$STUB_LIB"
label="rails $*"
rec "$label"; die_if_fail "$label"
if [ "${1:-}" = runner ]; then echo "${STUB_HAS_DATA:-false}"; fi
EOF
# Env markers prove the SaaS gemfile selection survives however this
# command is dispatched: bin/minio-setup boots config/environment directly,
# so it lives or dies by BUNDLE_GEMFILE.
cat > "$sb/bin/minio-setup" <<'EOF'
#!/usr/bin/env bash
source "$STUB_LIB"
label="minio-setup${*:+ $*} [SAAS=${SAAS:-unset} BUNDLE_GEMFILE=${BUNDLE_GEMFILE:-unset}]"
rec "$label"; die_if_fail "$label"
EOF
cat > "$sb/home/Work/basecamp/docker-dev/setup" <<'EOF'
#!/usr/bin/env bash
source "$STUB_LIB"
label="docker-dev/setup $*"
rec "$label"; die_if_fail "$label"
EOF
cat > "$sb/stubbin/docker" <<'EOF'
#!/usr/bin/env bash
source "$STUB_LIB"
label="docker $*"
rec "$label"; die_if_fail "$label"
if [ "${1:-}" = ps ] && [ "${STUB_MYSQL_CONTAINER:-0}" = 1 ]; then echo "3fa85f64c001"; fi
EOF
cat > "$sb/stubbin/nc" <<'EOF'
#!/usr/bin/env bash
source "$STUB_LIB"
label="nc $*"
rec "$label"; die_if_fail "$label"
if [ "${STUB_MYSQL_RUNNING:-0}" = 1 ]; then exit 0; else exit 1; fi
EOF
for stub in brew bundle gh; do
cat > "$sb/stubbin/$stub" < "$sb/stubbin/uname" <<'EOF'
#!/usr/bin/env bash
echo Darwin
EOF
# Not a recorder: gum is presentation. --version fails so step() takes its
# deterministic plain-echo fallback; direct `gum style` calls print their
# text arguments.
cat > "$sb/stubbin/gum" <<'EOF'
#!/usr/bin/env bash
case "${1:-}" in
--version) exit 1 ;;
style)
shift
while [ $# -gt 0 ] && [ "${1#--}" != "$1" ]; do
case "$1" in --foreground) shift 2 ;; *) shift ;; esac
done
printf '%s\n' "$*"
;;
esac
EOF
# Orchestration, not an effect: never recorded. run/tasks/trust exec the
# real mise against the sandbox's TOML; --skip-tools keeps the harness
# offline (installing tools is the prelude's job, outside this bracket).
cat > "$sb/stubbin/mise" <<'EOF'
#!/usr/bin/env bash
label="mise $*"
case "$label" in "${STUB_FAIL:-__none__}"*) echo "stub-fail: $label" >&2; exit 9;; esac
case "${1:-}" in
run) shift; exec "$STUB_REAL_MISE" run --skip-tools "$@" ;;
tasks|trust) exec "$STUB_REAL_MISE" "$@" ;;
--version) exec "$STUB_REAL_MISE" --version ;;
*) exit 0 ;;
esac
EOF
chmod +x "$sb/bin/rails" "$sb/bin/minio-setup" "$sb/home/Work/basecamp/docker-dev/setup" \
"$sb/stubbin/docker" "$sb/stubbin/nc" "$sb/stubbin/brew" "$sb/stubbin/bundle" \
"$sb/stubbin/gh" "$sb/stubbin/gum" "$sb/stubbin/mise" "$sb/stubbin/uname"
}
run_scenario() { # run_scenario [VAR=value|SAAS_FILE=1|MINIO_FILE=1]...
local name="$1" args="$2"; shift 2
local sb="$WORK/$name"
build_sandbox "$sb"
local envs=(
PATH="$sb/stubbin:/usr/bin:/bin:/usr/sbin:/sbin"
HOME="$sb/home"
TMPDIR="$sb/tmpdir"
TERM=dumb
STUB_SANDBOX="$sb"
STUB_RECORD="$sb/commands.txt"
STUB_LIB="$sb/stubbin/_stublib"
STUB_REAL_MISE="$REAL_MISE"
XDG_CONFIG_HOME="$sb/home/.config"
XDG_DATA_HOME="$sb/home/.local/share"
XDG_STATE_HOME="$sb/home/.local/state"
XDG_CACHE_HOME="$sb/home/.cache"
MISE_TRUSTED_CONFIG_PATHS="$sb"
MISE_GLOBAL_CONFIG_FILE="$sb/home/global-mise.toml"
)
local kv
for kv in "$@"; do
case "$kv" in
SAAS_FILE=1) touch "$sb/tmp/saas.txt" ;;
MINIO_FILE=1) touch "$sb/tmp/minio-dev.txt" ;;
*) envs+=("$kv") ;;
esac
done
: > "$sb/commands.txt"
local rc=0
( cd "$sb" && env -i "${envs[@]}" bin/setup $args ) > "$sb/output.raw" 2>&1 || rc=$?
printf 'exit: %s\n' "$rc" >> "$sb/commands.txt"
# ANSI escapes (mise colors some lines even under TERM=dumb, and coloring
# varies by version/environment) are styling, not behavior — strip them so
# the goldens stay stable.
local esc; esc="$(printf '\033')"
sed -e "s/${esc}\[[0-9;]*[A-Za-z]//g" \
-e "s|$sb/home|\$HOME|g" -e "s|$sb|\$APP|g" \
-e 's/([0-9][0-9]* sec)/(N sec)/' "$sb/output.raw" > "$sb/output.txt"
if [ "${REGEN:-}" = 1 ]; then
mkdir -p "$FIXTURES/$name"
cp "$sb/commands.txt" "$FIXTURES/$name/commands.txt"
cp "$sb/output.txt" "$FIXTURES/$name/output.txt"
echo "regenerated: $name (exit: $rc)"
else
echo "=== $name"
if diff -u "$FIXTURES/$name/commands.txt" "$sb/commands.txt" > "$sb/commands.diff" 2>&1; then
ok "commands match golden"
else
bad "commands diverge from golden"; sed 's/^/ /' "$sb/commands.diff"
fi
if diff -u "$FIXTURES/$name/output.txt" "$sb/output.txt" > "$sb/output.diff" 2>&1; then
ok "output matches golden"
else
bad "output diverges from golden"; sed 's/^/ /' "$sb/output.diff"
fi
fi
}
# State scenarios: every post-prelude branch of bin/setup.
run_scenario oss-sqlite "" STUB_HAS_DATA=true
run_scenario oss-sqlite-seeded "" STUB_HAS_DATA=false
run_scenario oss-reset "--reset" STUB_HAS_DATA=true
run_scenario oss-mysql-fresh "" STUB_HAS_DATA=true DATABASE_ADAPTER=mysql
run_scenario oss-mysql-container "" STUB_HAS_DATA=true DATABASE_ADAPTER=mysql STUB_MYSQL_CONTAINER=1
run_scenario oss-mysql-running "" STUB_HAS_DATA=true DATABASE_ADAPTER=mysql STUB_MYSQL_RUNNING=1
run_scenario saas-minio "" STUB_HAS_DATA=true SAAS_FILE=1 MINIO_FILE=1
run_scenario saas-no-minio "" STUB_HAS_DATA=true SAAS_FILE=1
run_scenario ci-sim "" STUB_HAS_DATA=false CI=true
# Failure-injection scenarios: exit status and stop point are part of the
# contract. fail-mysql-pull pins the deliberate non-errexit interior of the
# MySQL creation block — a failed pull falls through to `docker run` and the
# phase still succeeds, exactly as bin/setup has always behaved.
run_scenario fail-db-prepare "" STUB_HAS_DATA=false "STUB_FAIL=rails db:prepare"
run_scenario fail-seed "" STUB_HAS_DATA=false "STUB_FAIL=rails db:seed"
run_scenario fail-mysql-pull "" STUB_HAS_DATA=true DATABASE_ADAPTER=mysql "STUB_FAIL=docker pull mysql:8.4"
run_scenario fail-saas-minio-config "" STUB_HAS_DATA=true SAAS_FILE=1 MINIO_FILE=1 "STUB_FAIL=minio-setup"
echo
if [ "${REGEN:-}" = 1 ]; then
echo "goldens regenerated in $FIXTURES"
elif [ "$fail" = 0 ]; then
echo "ALL $pass ASSERTIONS PASSED"
else
echo "$fail FAILED, $pass passed"
exit 1
fi