Files
qbz/scripts/qbzd-acceptance.sh
T

245 lines
9.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# qbzd P0 acceptance -- scripted checks (05-implementation-plan.md T16).
#
# The human-only steps (J1 fresh-Pi journey, J2 desktop-handoff, the >24h JWT
# soak, and the sign-off sheet) live in
# qbz-nix-docs/qbz-daemon/acceptance/P0-acceptance.md. This script is the
# automatable slice only.
#
# SAFETY: runs the PREBUILT `qbzd` binary -- it never invokes cargo/rustc, so
# it is safe to run on a box where a build is already in flight elsewhere
# (global constraint: one build at a time, box-wide). It boots the daemon
# against an ISOLATED scratch profile root (env-driven XDG_CONFIG_HOME /
# XDG_DATA_HOME / XDG_CACHE_HOME) on a non-default port -- it never reads or
# writes the real ~/.config/qbzd, ~/.local/share/qbzd or any desktop qbz
# profile, and never touches systemd user units. Safe to run next to a real
# qbzd/qbz install.
#
# Usage:
# ./scripts/qbzd-acceptance.sh
# QBZD_BIN=/path/to/qbzd ./scripts/qbzd-acceptance.sh
# QBZD_TEST_PORT=28182 ./scripts/qbzd-acceptance.sh
# QBZD_KEEP_SCRATCH=1 ./scripts/qbzd-acceptance.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
QBZD_BIN="${QBZD_BIN:-$ROOT/crates/target/release/qbzd}"
PORT="${QBZD_TEST_PORT:-28182}"
fail() { echo "FAIL: $1" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || fail "curl is required"
command -v python3 >/dev/null 2>&1 || fail "python3 is required (status/ping/info shape checks)"
command -v timeout >/dev/null 2>&1 || fail "timeout (coreutils) is required"
[ -x "$QBZD_BIN" ] || fail "qbzd binary not found/executable at $QBZD_BIN -- build it first (release, on its own: cargo build --release -p qbzd)"
# ---------------------------------------------------------------------------
# Isolated scratch profile root. NEVER the real daemon/desktop roots: dirs::
# config_dir()/data_dir()/cache_dir() (crates/qbzd/src/paths.rs) honor these
# three env vars on Linux, and every qbzd invocation (daemon and CLI alike)
# resolves its roots this same way (main.rs: ProfileRoots::resolve(None, None)
# everywhere) -- so exporting them here is sufficient isolation for the whole
# script, with no --config/--data-root flag needed.
# ---------------------------------------------------------------------------
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/qbzd-acceptance.XXXXXX")"
export XDG_CONFIG_HOME="$SCRATCH/config"
export XDG_DATA_HOME="$SCRATCH/data"
export XDG_CACHE_HOME="$SCRATCH/cache"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
# Belt-and-braces: refuse to run if isolation somehow didn't take.
case "$XDG_CONFIG_HOME" in
"$HOME"/.config|"$HOME"/.config/*) fail "refusing to run: XDG_CONFIG_HOME resolved under \$HOME/.config -- isolation broke" ;;
esac
QBZD_HOST="127.0.0.1:$PORT"
LOGFILE="$SCRATCH/qbzd.log"
DAEMON_PID=""
# Escalating kill helper: SIGTERM → poll 5s → SIGKILL → poll 2s.
# Returns 0 if confirmed dead, 1 if still alive after SIGKILL.
# If still alive after SIGKILL, prints a warning with the PID.
kill_and_confirm() {
local pid=$1
[ -n "$pid" ] || return 0
# SIGTERM and poll for up to 5 seconds (25 x 0.2s).
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 25); do kill -0 "$pid" 2>/dev/null || return 0; sleep 0.2; done
# Still alive; escalate to SIGKILL and poll for up to 2 seconds (10 x 0.2s).
kill -KILL "$pid" 2>/dev/null || true
for _ in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || return 0; sleep 0.2; done
# Still alive after SIGKILL; print warning and return 1.
echo "WARNING: qbzd (PID $pid) still alive after SIGKILL" >&2
return 1
}
cleanup() {
local exit_status=$?
if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill_and_confirm "$DAEMON_PID" || true
fi
if [ "$exit_status" -ne 0 ] || [ "${QBZD_KEEP_SCRATCH:-0}" = "1" ]; then
echo "qbzd acceptance scratch preserved at: $SCRATCH" >&2
else
rm -rf "$SCRATCH"
fi
return "$exit_status"
}
trap cleanup EXIT
# Never steal a live port -- if something already answers, stop rather than guess.
if curl -fsS -m 1 "127.0.0.1:${PORT}/api/ping" >/dev/null 2>&1; then
fail "something is already answering on 127.0.0.1:${PORT} -- set QBZD_TEST_PORT to a free port"
fi
qbzd() { "$QBZD_BIN" --host "$QBZD_HOST" "$@"; }
write_config() {
mkdir -p "$XDG_CONFIG_HOME/qbzd"
# Pins the test port (never 8182 -- avoids colliding with a real daemon) and
# plants one unrecognized key so the 01 §10.2 unknown-key startup warning is
# exercised on every boot this script does.
cat > "$XDG_CONFIG_HOME/qbzd/qbzd.toml" <<EOF
config_version = 1
acceptance_script_marker = "unknown key on purpose (01 section 10.2)"
[server]
bind = "127.0.0.1"
port = $PORT
EOF
}
start_daemon() {
write_config
: > "$LOGFILE"
"$QBZD_BIN" run >>"$LOGFILE" 2>&1 &
DAEMON_PID=$!
for _ in $(seq 1 50); do
qbzd ping >/dev/null 2>&1 && return 0
kill -0 "$DAEMON_PID" 2>/dev/null || fail "daemon exited during boot -- see $LOGFILE"
sleep 0.2
done
fail "daemon did not answer ping within 10s -- see $LOGFILE"
}
stop_daemon() {
[ -n "$DAEMON_PID" ] || return 0
if kill_and_confirm "$DAEMON_PID"; then
DAEMON_PID=""
else
# Process still alive after escalation; leave DAEMON_PID set so cleanup() gets another chance.
true
fi
}
echo "== isolated-root boot (env-driven scratch XDG) =="
start_daemon
echo " scratch root: $SCRATCH"
echo "== exit-code table (02 section 1.3): version ok, unknown verb is usage error =="
qbzd version >/dev/null || fail "version != 0"
set +e
qbzd bogus-command-xyz >/dev/null 2>&1
rc_usage=$?
set -e
[ "$rc_usage" -eq 2 ] || fail "usage error != 2 (got $rc_usage)"
echo "== unknown-key config warning (01 section 10.2) =="
grep -q 'unknown key: acceptance_script_marker' "$LOGFILE" \
|| fail "boot did not warn about the unrecognized qbzd.toml key -- see $LOGFILE"
echo "== status answers 'why is it silent' in one call (02 section 3.3.3) =="
# This scratch daemon never logs in, so status is expected to report
# auth.state=needs_auth and the CLI exits 4 (02 section 1.3: "status exits
# nonzero on degraded state") -- that is the down-vs-unhealthy distinction
# working correctly, not a script failure, so capture rc separately from
# `set -e` and assert the SPECIFIC expected code rather than just >/dev/null.
set +e
status_json=$(qbzd status --json)
rc_status_needs_auth=$?
set -e
[ "$rc_status_needs_auth" -eq 4 ] || fail "status --json on an unauthenticated daemon != 4 (got $rc_status_needs_auth)"
echo "$status_json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for k in ('auth', 'audio', 'playback', 'qconnect', 'network', 'last_errors', 'driver_tick_age_ms'):
assert k in d, f'missing status key: {k}'
assert d['auth']['state'] == 'needs_auth', d['auth']
"
echo "== ping/info shape (02 section 3.3.1 / 3.3.2) =="
curl -fsS "127.0.0.1:${PORT}/api/ping" | python3 -c "
import json, sys
d = json.load(sys.stdin)
assert d.get('ok') is True, d
assert d.get('app') == 'qbzd', d
assert 'api_version' in d, d
"
curl -fsS "127.0.0.1:${PORT}/api/info" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for k in ('app', 'version', 'api_version', 'bind', 'uptime_secs', 'data_root'):
assert k in d, f'missing info key: {k}'
"
echo "== config show --json matches the on-disk port (02 section 2.2 config verb) =="
cfg_port=$(qbzd config show --json | python3 -c "import json,sys; print(json.load(sys.stdin)['server']['port'])")
[ "$cfg_port" = "$PORT" ] || fail "config show --json port ($cfg_port) != expected test port ($PORT)"
echo "== export/import roundtrip is a no-op (04 section 7) =="
# The 04 section 7 roundtrip guarantee assumes a daemon "legitimately running"
# its own settings -- concretely, one that has already moved past the fresh
# store's quality_fallback_behavior=ask default (the TUI never writes "ask",
# 03 section 3.3.2; "settings set" rejects it outright, cli/settings.rs). A
# virgin store that never ran `qbzd setup`/`settings set` still holds "ask",
# which the section 5.5 mapping unconditionally reports as `adapted` -- so
# prime it first, exactly like a real first-run setup would, before proving
# the no-op invariant.
qbzd settings set audio.quality_fallback_behavior always_fallback >/dev/null
BUNDLE="$SCRATCH/rt.qbzb"
qbzd settings export "$BUNDLE" >/dev/null
out=$(qbzd settings import "$BUNDLE" --dry-run)
echo "$out" | grep -q "adapted (0)" || fail "roundtrip produced adaptations (no-change short-circuit broken -- 04 section 5.3 step 4)"
echo "$out" | grep -qE '^applied \([0-9]+\)' || fail "roundtrip import produced no applied-bucket summary"
rm -f "$BUNDLE"
echo "== route budget: unknown route 404s, ping open, Origin rejected (02 section 3.1.2 / 3.1.4) =="
curl -fsS "127.0.0.1:${PORT}/api/ping" >/dev/null || fail "open ping"
code=$(curl -s -o /dev/null -w '%{http_code}' "127.0.0.1:${PORT}/api/nope")
[ "$code" = "404" ] || fail "unknown route: $code"
ocode=$(curl -s -o /dev/null -w '%{http_code}' -H 'Origin: http://x' "127.0.0.1:${PORT}/api/status")
[ "$ocode" = "403" ] || fail "origin shield: $ocode"
echo "== daemon-down: ping/status exit 3 (02 section 1.3) =="
stop_daemon
set +e
qbzd ping >/dev/null 2>&1
rc_ping=$?
qbzd status >/dev/null 2>&1
rc_status=$?
set -e
[ "$rc_ping" -eq 3 ] || fail "ping daemon-down != 3 (got $rc_ping)"
[ "$rc_status" -eq 3 ] || fail "status daemon-down != 3 (got $rc_status)"
echo "== instance lock: a second 'qbzd run' on the same root exits 3 (01 section 8.3) =="
start_daemon
set +e
timeout 5 "$QBZD_BIN" run >>"$LOGFILE" 2>&1
rc_second=$?
set -e
[ "$rc_second" -eq 3 ] || fail "second 'qbzd run' on the same data root != 3 (got $rc_second)"
qbzd ping >/dev/null || fail "the first daemon stopped answering after the double-start attempt"
echo "== non-tty 'qbzd setup' exits 2, never hangs (03 section 2.4) =="
set +e
timeout 5 "$QBZD_BIN" setup </dev/null >/dev/null 2>&1
rc_setup=$?
set -e
[ "$rc_setup" -eq 2 ] || fail "non-tty setup != 2 (got $rc_setup)"
stop_daemon
echo "ALL SCRIPTED CHECKS PASSED"