Ctrl K
ComunidadGuías…

scripts/test_freebsd.sh

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# Run BleachBit `make tests` inside a transient FreeBSD 15 virtual machine.
#
# What this script does:
#   1. Downloads a FreeBSD 15.1-RELEASE amd64 cloud-init qcow2 image (cached).
#   2. Builds a NoCloud seed ISO that injects an SSH public key and a small
#      bootstrap user-data script.
#   3. Boots the image under QEMU/KVM with a port-forwarded SSH (host:2222 ->
#      guest:22). A snapshot qcow2 is used so the downloaded image stays
#      read-only and the VM is ephemeral.
#   4. Copies the BleachBit source tree into the VM via scp (tar stream).
#   5. Bootstraps bash, then runs scripts/install-deps.sh --dev inside the VM
#      to install FreeBSD package dependencies (single source of truth shared
#      with Linux/macOS).
#   6. Builds translations (`gmake -C po local`), then runs `make tests`
#      (or $TEST_TARGET) and relays its exit code.
#   7. Tears down the VM and removes temp files, unless the VM is being
#      persisted for reuse (KEEP_VM=1 or SKIP_INSTALL=1), in which case the
#      snapshot, seed ISO, SSH key, and VM metadata are kept in $CACHE_DIR/vm.
#
# Fast iteration: run once with KEEP_VM=1 to build and persist a VM, then
# repeat with SKIP_INSTALL=1 (and optionally TEST_TARGET="python3 -m unittest
# tests.TestSpecial -v") to skip the download, package install, and full
# test suite, re-syncing only the source tree and re-running the test.
#
# A persisted VM is only reusable under the configuration it was created with:
# the same FREEBSD_IMAGE, an intact SSH keypair/seed ISO pair, and the same
# CACHE_DIR with the base image still present. The script refuses to reuse a
# VM when any of these no longer match; remove $CACHE_DIR/vm to rebuild from
# scratch.
#
# The persisted snapshot ($CACHE_DIR/vm/disk.qcow2) is a copy-on-write overlay
# over the cached base image: it stores only the changes and reads everything
# else from the base image file. Keep CACHE_DIR stable and keep the base image
# while a persisted VM exists; deleting or replacing the base image (or moving
# CACHE_DIR) makes the snapshot unusable.
#
# GTK is not installed: the VM has no display server (-display none, no
# DISPLAY/WAYLAND_DISPLAY), so bleachbit.GtkShim.is_gtk_available() returns
# False and all GUI tests self-skip. Only the headless test suite runs.
#
# Usage:
#   ./scripts/test_freebsd.sh
#
# Environment overrides:
#   FREEBSD_VERSION   FreeBSD release version (default: 15.1)
#   FREEBSD_IMAGE     qcow2 base filename (default:
#                     FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2)
#   FREEBSD_URL       Full download URL (overrides the constructed one)
#   SSH_PORT          host port forwarded to guest:22 (default: 2222)
#   VM_MEM            VM RAM in MB (default: 4096)
#   VM_CPUS           VM vCPUs (default: 2)
#   VM_DISK           extra qcow2 size to grow the image, e.g. 10G (default: 8G)
#   SSH_USER          login user inside the image (default: freebsd)
#   KEEP_VM           if "1", persist the VM (snapshot qcow2, seed ISO, SSH
#                     key) in a stable directory under CACHE_DIR so a later
#                     run can reuse it. Implies a stable WORK_DIR.
#   SKIP_INSTALL      if "1", reuse a persisted VM (see KEEP_VM) and skip the
#                     install-deps.sh --dev block, going straight to copying
#                     the source tree and running tests. Requires that a VM
#                     was previously persisted with KEEP_VM=1. Implies a
#                     stable WORK_DIR.
#   TEST_TARGET       command to run instead of `make tests` (e.g.
#                     "python3 -m unittest tests.TestSpecial -v"). Useful for
#                     fast iteration on a single test module.
#   SKIP_DOWNLOAD     if "1", reuse an already-downloaded image in CACHE_DIR
#   CACHE_DIR         where to store the downloaded image and persisted VM
#                     (default: $HOME/.cache/bleachbit-vm); keep it stable
#                     while a persisted VM exists (see note above)
#
# Requires on the Ubuntu host:
#   qemu-system-x86_64, qemu-img, xorriso, ssh, scp, and /dev/kvm (recommended;
#   falls back to TCG if KVM is unavailable).

set -euo pipefail

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
FREEBSD_VERSION="${FREEBSD_VERSION:-15.1}"
FREEBSD_IMAGE="${FREEBSD_IMAGE:-FreeBSD-${FREEBSD_VERSION}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2}"
FREEBSD_URL="${FREEBSD_URL:-https://download.freebsd.org/releases/VM-IMAGES/${FREEBSD_VERSION}-RELEASE/amd64/Latest/${FREEBSD_IMAGE}.xz}"
SSH_PORT="${SSH_PORT:-2222}"
VM_MEM="${VM_MEM:-4096}"
VM_CPUS="${VM_CPUS:-2}"
VM_DISK="${VM_DISK:-8G}"
SSH_USER="${SSH_USER:-freebsd}"
KEEP_VM="${KEEP_VM:-0}"
SKIP_DOWNLOAD="${SKIP_DOWNLOAD:-0}"
CACHE_DIR="${CACHE_DIR:-$HOME/.cache/bleachbit-vm}"
SKIP_INSTALL="${SKIP_INSTALL:-0}"
TEST_TARGET="${TEST_TARGET:-}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

mkdir -p "$CACHE_DIR"
CACHED_XZ="$CACHE_DIR/${FREEBSD_IMAGE}.xz"
CACHED_IMG="$CACHE_DIR/$FREEBSD_IMAGE"

# log/err are defined early so the WORK_DIR guard below can use them.
log() { echo "[freebsd-test] $*"; }
err() { echo "[freebsd-test] ERROR: $*" >&2; }

# A stable WORK_DIR (under CACHE_DIR) is used whenever the user wants the VM
# to persist across invocations (KEEP_VM=1 or SKIP_INSTALL=1). Otherwise use a
# throwaway mktemp directory that the trap cleans up.
if [[ "$SKIP_INSTALL" == "1" || "$KEEP_VM" == "1" ]]; then
    WORK_DIR="$CACHE_DIR/vm"
    mkdir -p "$WORK_DIR"
else
    WORK_DIR="$(mktemp -d -t bleachbit-freebsd-XXXXXX)"
fi
SNAPSHOT_IMG="$WORK_DIR/disk.qcow2"
SEED_ISO="$WORK_DIR/seed.iso"
SSH_KEY="$WORK_DIR/id_ed25519"
SSH_PUB="$WORK_DIR/id_ed25519.pub"
VM_LOG="$WORK_DIR/vm.log"
VM_PIDFILE="$WORK_DIR/vm.pid"
VM_META="$WORK_DIR/vm.meta"
DEPS_MARKER="$WORK_DIR/deps-installed"

# REUSE_VM=1 means a persisted snapshot already exists in WORK_DIR and we can
# boot it directly instead of creating a fresh one off the base image.
REUSE_VM=0
if [[ -f "$SNAPSHOT_IMG" ]]; then
    REUSE_VM=1
fi

# A persisted snapshot is tied to the base image it was created from. Refuse
# to reuse it for a different image (e.g. after FREEBSD_VERSION changed),
# which would silently test the wrong release.
if [[ "$REUSE_VM" == "1" ]]; then
    if [[ ! -f "$VM_META" ]]; then
        err "Persisted VM in $WORK_DIR has no $VM_META marker."
        err "Remove $WORK_DIR to rebuild the VM, or restore the marker."
        exit 1
    fi
    stored_image="$(sed -n 's/^FREEBSD_IMAGE=//p' "$VM_META" 2>/dev/null)" || true
    if [[ -z "$stored_image" || "$stored_image" != "$FREEBSD_IMAGE" ]]; then
        err "Persisted VM in $WORK_DIR was created with image: ${stored_image:-unknown}"
        err "but FREEBSD_IMAGE is now: $FREEBSD_IMAGE"
        err "Remove $WORK_DIR (e.g. 'rm -rf \"$WORK_DIR\"') to rebuild the VM"
        err "for the new image, or set FREEBSD_IMAGE=$stored_image to reuse it."
        exit 1
    fi
fi

if [[ "$SKIP_INSTALL" == "1" ]]; then
    if [[ "$REUSE_VM" != "1" ]]; then
        err "SKIP_INSTALL=1 requires an existing persisted VM in $WORK_DIR."
        err "Run ./scripts/test_freebsd.sh with KEEP_VM=1 first to create one."
        exit 1
    fi
    if [[ ! -f "$DEPS_MARKER" ]]; then
        err "SKIP_INSTALL=1, but dependencies were never installed successfully"
        err "in this persisted VM (missing $DEPS_MARKER)."
        err "Run ./scripts/test_freebsd.sh with KEEP_VM=1 to complete the"
        err "dependency installation, or remove $WORK_DIR to rebuild."
        exit 1
    fi
fi

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ssh_opts=(
    -i "$SSH_KEY"
    -o IdentitiesOnly=yes
    -o StrictHostKeyChecking=no
    -o UserKnownHostsFile=/dev/null
    -o LogLevel=ERROR
    -o ConnectTimeout=10
    -p "$SSH_PORT"
)

# shellcheck disable=SC2329  # invoked by the trap below
cleanup() {
    local rc=$?
    local pid=""
    local persist=0
    [[ "$KEEP_VM" == "1" || "$SKIP_INSTALL" == "1" ]] && persist=1
    [[ -f "$VM_PIDFILE" ]] && pid="$(cat "$VM_PIDFILE" 2>/dev/null || true)"
    if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
        if [[ "$persist" == "1" ]]; then
            # The snapshot outlives this run, so power the guest down cleanly
            # instead of killing QEMU (which is like yanking the plug and can
            # leave the root filesystem dirty for the next boot). The tests
            # are already done at this point, so a poweroff is safe. Fall back
            # to kill if the guest does not exit promptly.
            log "Requesting graceful VM shutdown (pid $pid)"
            ssh "${ssh_opts[@]}" "root@localhost" "shutdown -p now" 2>/dev/null || true
            for _ in $(seq 1 40); do
                kill -0 "$pid" 2>/dev/null || break
                sleep 0.5
            done
        fi
        if kill -0 "$pid" 2>/dev/null; then
            log "Shutting down VM (pid $pid)"
            kill "$pid" 2>/dev/null || true
            # Wait briefly, then force.
            for _ in $(seq 1 20); do
                kill -0 "$pid" 2>/dev/null || break
                sleep 0.5
            done
            kill -9 "$pid" 2>/dev/null || true
        fi
    fi
    # Only wipe WORK_DIR when it's a throwaway mktemp dir. The stable dir
    # ($CACHE_DIR/vm) is used by KEEP_VM=1 and SKIP_INSTALL=1 and must survive
    # so the VM can be reused on a later run.
    if [[ "$persist" == "1" ]]; then
        log "Leaving persisted VM intact in $WORK_DIR"
    else
        rm -rf "$WORK_DIR"
    fi
    exit "$rc"
}
trap cleanup EXIT INT TERM

# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------
for bin in qemu-system-x86_64 qemu-img xorriso ssh scp; do
    if ! command -v "$bin" >/dev/null 2>&1; then
        err "missing required binary: $bin"
        exit 1
    fi
done

ACCEL=tcg
if [[ -w /dev/kvm ]]; then
    ACCEL=kvm
else
    log "WARNING: /dev/kvm not writable; falling back to TCG (slow)."
fi

# ---------------------------------------------------------------------------
# Download (and cache) the FreeBSD image
# ---------------------------------------------------------------------------
if [[ ! -f "$CACHED_IMG" ]]; then
    if [[ "$SKIP_DOWNLOAD" == "1" ]]; then
        err "SKIP_DOWNLOAD=1 but $CACHED_IMG is missing."
        err "Note: a persisted VM snapshot also needs this base image as its"
        err "backing file, so it must stay cached while the VM is kept."
        exit 1
    fi
    if [[ ! -f "$CACHED_XZ" ]]; then
        log "Downloading $FREEBSD_URL"
        # Keep a partial download under .part so the next run resumes it
        # instead of trying to decompress it.
        wget -c -O "$CACHED_XZ.part" "$FREEBSD_URL"
        mv "$CACHED_XZ.part" "$CACHED_XZ"
    fi
    log "Decompressing $CACHED_XZ"
    # xz -dk writes the output next to the .xz file (same dir, name minus .xz),
    # which is exactly $CACHED_IMG, so no mv is needed.
    xz -dk -T0 "$CACHED_XZ"
else
    log "Using cached image $CACHED_IMG"
fi

# ---------------------------------------------------------------------------
# SSH keypair + NoCloud seed ISO (a matched set)
# ---------------------------------------------------------------------------
# The seed ISO embeds the public key, so the keypair and seed ISO must stay
# in sync: reuse both together, or rebuild both together. When a persisted VM
# is reused but one of them is missing, the key baked into the VM on first
# boot is unrecoverable without SSH access, so fail with a clear error.
KEY_PRESENT=0
[[ -f "$SSH_KEY" && -f "$SSH_PUB" ]] && KEY_PRESENT=1
SEED_PRESENT=0
[[ -f "$SEED_ISO" ]] && SEED_PRESENT=1

if [[ "$KEY_PRESENT" == "1" && "$SEED_PRESENT" == "1" ]]; then
    log "Reusing SSH keypair and seed ISO from $WORK_DIR"
elif [[ "$REUSE_VM" == "1" ]]; then
    err "Persisted VM found, but its SSH keypair or seed ISO is missing"
    err "(expected $SSH_KEY and $SEED_ISO)."
    err "The key injected into the VM on first boot is unrecoverable without"
    err "SSH access, so the VM cannot be reused."
    err "Remove $WORK_DIR (e.g. 'rm -rf \"$WORK_DIR\"') to rebuild the VM,"
    err "or restore the missing file(s)."
    exit 1
else
    # Fresh VM: generate a matched keypair and seed ISO together.
    log "Generating SSH keypair"
    ssh-keygen -q -t ed25519 -N "" -f "$SSH_KEY" -C bleachbit-freebsd-test

    INSTANCE_ID="bleachbit-$(date +%s)"

    # user-data: add SSH key for the freebsd user and grow root filesystem.
    # The base image uses `nuageinit` (FreeBSD's built-in cloud-init alternative)
    # to process the NoCloud seed. It handles ssh_authorized_keys and growpart.
    # Root access is obtained via `su root -c` (freebsd is in wheel, and su
    # works without a password when root's password is `*`).
    cat >"$WORK_DIR/user-data" <<EOF
#cloud-config
instance-id: $INSTANCE_ID
ssh_authorized_keys:
  - $(cat "$SSH_PUB")
growpart:
  mode: auto
  devices: ['/']
  ignore_growoff: true
EOF

    cat >"$WORK_DIR/meta-data" <<EOF
instance-id: $INSTANCE_ID
local-hostname: bleachbit-freebsd
EOF

    log "Building NoCloud seed ISO with xorriso"
    xorriso -as mkisofs -V cidata -rock -joliet -no-emul-boot \
        -o "$SEED_ISO" "$WORK_DIR/meta-data" "$WORK_DIR/user-data"
fi

# ---------------------------------------------------------------------------
# Create a snapshot qcow2 backed by the cached image, then grow it
# ---------------------------------------------------------------------------
if [[ "$REUSE_VM" == "1" ]]; then
    log "Reusing existing VM disk $SNAPSHOT_IMG"
    # disk.qcow2 is a copy-on-write overlay: unchanged blocks are read from
    # the base image whose absolute path is recorded in the snapshot. Verify
    # that path still matches the cache, so a CACHE_DIR change or a deleted
    # base image fails clearly here instead of with a cryptic qemu error (or
    # worse, silent corruption if the base image was replaced).
    backing="$(qemu-img info "$SNAPSHOT_IMG" 2>/dev/null | sed -n 's/^backing file: //p')" || true
    if [[ -z "$backing" || "$backing" != "$CACHED_IMG" ]]; then
        err "Persisted VM $SNAPSHOT_IMG expects backing image: ${backing:-<none>}"
        err "but the current cache holds: $CACHED_IMG"
        err "Keep CACHE_DIR stable and keep the base image around, or remove"
        err "$WORK_DIR to rebuild the VM from scratch."
        exit 1
    fi
else
    log "Creating snapshot qcow2 backed by $CACHED_IMG"
    qemu-img create -f qcow2 -F qcow2 -b "$CACHED_IMG" "$SNAPSHOT_IMG"
    # Grow the virtual size so pkg install + source tree fit comfortably.
    qemu-img resize "$SNAPSHOT_IMG" "+$VM_DISK"
    # Record which base image this snapshot was created from, so a later run
    # with a different FREEBSD_IMAGE (e.g. another FREEBSD_VERSION) is refused
    # instead of silently reusing the wrong VM.
    echo "FREEBSD_IMAGE=$FREEBSD_IMAGE" >"$VM_META"
fi

# ---------------------------------------------------------------------------
# Boot the VM
# ---------------------------------------------------------------------------
log "Booting FreeBSD VM (accel=$ACCEL, ${VM_MEM}M, ${VM_CPUS} cpus, ssh -> :$SSH_PORT)"
qemu-system-x86_64 \
    -accel "$ACCEL" \
    -m "$VM_MEM" \
    -smp "$VM_CPUS" \
    -drive file="$SNAPSHOT_IMG",if=virtio,format=qcow2 \
    -drive file="$SEED_ISO",if=virtio,format=raw,readonly=on,media=cdrom \
    -netdev user,id=net0,hostfwd=tcp::"${SSH_PORT}"-:22 \
    -device virtio-net-pci,netdev=net0 \
    -display none \
    -serial file:"$VM_LOG" \
    -pidfile "$VM_PIDFILE" \
    >"$VM_LOG" 2>&1 &

QEMU_PID=$!
# qemu writes the pidfile slightly later than fork; ensure it exists.
for _ in $(seq 1 20); do
    [[ -f "$VM_PIDFILE" ]] && break
    sleep 0.5
done

# ---------------------------------------------------------------------------
# Wait for SSH (freebsd user) to come up
# ---------------------------------------------------------------------------
log "Waiting for SSH on localhost:$SSH_PORT (started at $(date '+%H:%M:%S'), this can take a few minutes)"
SSH_READY=0
for _ in $(seq 1 120); do
    if ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" true 2>/dev/null; then
        SSH_READY=1
        break
    fi
    if ! kill -0 "$QEMU_PID" 2>/dev/null; then
        err "QEMU exited before SSH became ready. Last serial output:"
        tail -n 80 "$VM_LOG" >&2 || true
        exit 1
    fi
    sleep 5
done
if [[ "$SSH_READY" != "1" ]]; then
    err "Timed out waiting for SSH. Last serial output:"
    tail -n 80 "$VM_LOG" >&2 || true
    exit 1
fi
log "Freebsd SSH is up"

# ---------------------------------------------------------------------------
# Get root access: the freebsd user is in wheel, and `su root -c 'cmd'`
# works without a password on FreeBSD when root's password is `*` (locked
# for direct login but su from wheel is allowed via PAM).
# ---------------------------------------------------------------------------
log "Verifying root access via su"
if ! ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" "su root -c 'whoami'" 2>&1 | grep -q '^root$'; then
    err "su root -c 'whoami' did not return 'root'. Cannot get root access."
    exit 1
fi
log "Root access via su works (no password needed)"

# Helper: run a command as root in the VM via su (no password needed).
vm_root() {
    # shellcheck disable=SC2029  # $1 is meant to expand here, not in the VM
    ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" "su root -c '$1'"
}

# ---------------------------------------------------------------------------
# Set up root SSH access for easier batch commands
# ---------------------------------------------------------------------------
log "Setting up root SSH access"

# Pipe the public key through stdin to avoid quoting issues with the key
# content (which contains spaces).
cat "$SSH_PUB" | ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" \
    "su root -c 'mkdir -p /root/.ssh && cat > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys'"

# Enable PermitRootLogin with keys only. Use a heredoc to avoid quoting issues.
ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" 'su root -c "grep -q ^PermitRootLogin /etc/ssh/sshd_config && sed -i \"\" \"s/^PermitRootLogin.*/PermitRootLogin prohibit-password/\" /etc/ssh/sshd_config || echo PermitRootLogin prohibit-password >> /etc/ssh/sshd_config"'

vm_root "service sshd restart"
sleep 3

# Verify root SSH works.
if ssh "${ssh_opts[@]}" "root@localhost" true 2>/dev/null; then
    log "Root SSH is up"
else
    err "Root SSH verification failed after setup."
    exit 1
fi

# ---------------------------------------------------------------------------
# Stream the BleachBit source tree into the VM
# ---------------------------------------------------------------------------
# Use a directory under the freebsd user's home (writable without sudo).
REMOTE_DIR="bleachbit"
log "Copying BleachBit source tree to VM (~/$REMOTE_DIR)"
# Clear the old tree first so files deleted on the host don't linger.
# Exclude ./locale: it is a generated, gitignored build product of
# `make -C po local`, and copying a stale or partial one from the host
# would skew the translation tests in the VM (they are rebuilt in the VM
# before the test run below).
# shellcheck disable=SC2029  # $REMOTE_DIR is meant to expand here, not in the VM
tar -C "$REPO_ROOT" --exclude='.git' --exclude='__pycache__' \
    --exclude='*.pyc' --exclude='*.egg-info' --exclude='dist' \
    --exclude='build' --exclude='docker-artifacts' --exclude='.venv' \
    --exclude='./locale' \
    -czf - . \
    | ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" \
        "rm -rf ~/'$REMOTE_DIR' && mkdir -p ~/'$REMOTE_DIR' && tar -C ~/'$REMOTE_DIR' -xzf -"

# ---------------------------------------------------------------------------
# Install dependencies inside the VM (skipped when SKIP_INSTALL=1)
# ---------------------------------------------------------------------------
# Delegate to scripts/install-deps.sh --dev, which bootstraps Bash and shares
# one dependency list with Linux and macOS. GTK is intentionally not installed:
# the VM has no display server, so is_gtk_available() returns False and GUI
# tests self-skip.
if [[ "$SKIP_INSTALL" == "1" ]]; then
    log "SKIP_INSTALL=1: skipping dependency installation"
else
    log "Installing FreeBSD dependencies inside the VM"
    # Run the bootstrap as root via `sh -s` because Bash is not installed yet on
    # the base image. The installer starts with a POSIX sh bootstrap and then
    # re-execs itself with Bash for the test toolchain and runtime Python deps.
    REMOTE_SCRIPT=$(cat <<'REMOTE'
set -eu

export ASSUME_ALWAYS_YES=yes

echo "[vm] bootstrapping pkg"
pkg bootstrap -y

echo "[vm] pkg update"
pkg update -q

# Delegate the rest to the shared dependency installer. Run as root (we have
# root SSH), so install_freebsd() can run pkg. ~freebsd expands to the freebsd
# user's home, where the source tree was copied.
echo "[vm] running install-deps.sh --dev"
exec sh ~freebsd/bleachbit/scripts/install-deps.sh --dev
REMOTE
)

    ssh "${ssh_opts[@]}" "root@localhost" "sh -s" <<<"$REMOTE_SCRIPT" \
        || { rc=$?; err "FreeBSD dependency installation failed (exit $rc)"; exit $rc; }
    # Record success so a later SKIP_INSTALL=1 run knows the VM is fully
    # provisioned and never reuses a half-installed snapshot.
    touch "$DEPS_MARKER"
fi

# Run tests as the freebsd user (normal HOME, non-root file ownership).
# TEST_TARGET overrides the default `make tests` so a single test module can
# be re-run quickly during iteration.
if [[ -n "$TEST_TARGET" ]]; then
    RUN_CMD="$TEST_TARGET"
    LOG_LABEL="$TEST_TARGET"
else
    RUN_CMD="gmake tests"
    LOG_LABEL="make tests"
fi
log "Running $LOG_LABEL as $SSH_USER"
# Unquoted heredoc so $RUN_CMD expands locally; the rest contains no $ that
# needs to reach the remote shell verbatim. MAKE/PYTHON are exported so the
# default `gmake tests` invocation finds GNU make and python3 on FreeBSD.
TEST_SCRIPT=$(cat <<REMOTE
set -eu
cd ~/bleachbit
export MAKE=gmake PYTHON=python3
# Build local translations the same way CI does (make -C po local) so the
# language tests exercise real .mo files. Without this they would skip --
# or fail if the host had a partial locale/ tree.
echo "[vm] building translations (gmake -C po local)"
gmake -C po local
echo "[vm] running: $RUN_CMD"
if ! $RUN_CMD; then
    echo "[vm] test command FAILED" >&2
    exit 1
fi
echo "[vm] test command PASSED"
REMOTE
)

if ssh "${ssh_opts[@]}" "${SSH_USER}@localhost" "sh -s" <<<"$TEST_SCRIPT"; then
    log "$LOG_LABEL PASSED in FreeBSD VM"
    exit_code=0
else
    rc=$?
    err "$LOG_LABEL FAILED in FreeBSD VM (exit $rc)"
    exit_code=$rc
fi

# Cleanup runs via the trap.
exit "$exit_code"