Files
qbz/scripts/qbzd-acceptance.sh
T
vicrodh e8f4dd4aa3 build(qt): each worktree builds, and keeps its binary, in its own target
This workstation configures ONE Cargo target outside the checkouts for every
worktree below the project directory. A build in any worktree therefore
replaced the `qbz` another worktree had just built: a parallel session's
release build in its own worktree replaced the binary under test in the
main one.

scripts/qt-target.py now decides where builds go. When the target Cargo
resolves lies outside the worktree and nothing pins it (CARGO_TARGET_DIR or
CARGO_BUILD_TARGET_DIR), the worktree builds in its own sibling directory,
`<target>-worktrees/<worktree>`; rebuilding the same worktree still replaces
its own binary. Cargo's default target inside the checkout (macOS, CI,
Windows) and explicit overrides are untouched, and no machine path is
committed. Two checkouts that share a directory name get a stable suffix
instead of one target.

qt-run.sh, the runtime gate and direct qt-cargo.py builds and tests all use
it. qt-run.sh prints the target and the binary path, and re-points
crates/target after each successful build, never over a real directory.
The aarch64 scripts build into and install from the same target, and the
qbzd acceptance script names the build command that lands its binary there.

The first build of a profile in a new worktree target is seeded from the
shared target with copy-on-write clones, skipping incremental caches:
Cargo rewrites moved build-script paths through root-output
(custom_build.rs, "rewrite paths if the target directory has been moved"),
so unchanged crates are not compiled again and no data is duplicated.
Without clone support nothing is copied and Cargo builds from scratch.

The resolution and shortcut handling start from the uncommitted draft in
the build-storage-layout worktree. Verified on the main worktree: seeded
from the shared release target, Cargo found everything fresh in two
seconds, and crates/target now points at the worktree's own target. 33
build-gate tests cover derivation, overrides, name collisions, linking,
seeding and the runner end to end.

cc
2026-09-14 11:47:07 -06:00

245 lines
9.9 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, into this worktree's target: python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -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"