#!/usr/bin/env bash # # run.sh — E2E test suite for musicfs. # # Full distributed path: server in Incus VM serving a generated music library # over gRPC, local client mounting it as FUSE, tests run against the mountpoint. # # Requires: devenv shell (for ffmpeg, metaflac, id3v2, incus, grpcurl, Postgres) # # Usage: # scripts/e2e/run.sh full suite (creates VM, runs tests, destroys) # KEEP=1 scripts/e2e/run.sh keep VM + DB alive after run for debugging # set -euo pipefail # ─────────────────────────── config ──────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" MNT="$PROJECT_DIR/target/e2e/mnt" MUSIC_DIR="$PROJECT_DIR/target/e2e/music" LOG_DIR="$PROJECT_DIR/logs/e2e" DB_NAME="musicfs_e2e" VM_NAME="musicfs-e2e" PORT=50061 KEEP="${KEEP:-0}" RUST_LOG_VAL="${RUST_LOG:-musicfs=debug}" CLIENT_PID="" STARTED_PG="" . "$SCRIPT_DIR/lib.sh" log() { printf '\033[1;34m[e2e]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[e2e error]\033[0m %s\n' "$*" >&2; exit 1; } # ──────────────────────── postgres ───────────────────────────────────── ensure_postgres() { if pg_isready -h "$PGHOST" >/dev/null 2>&1; then return 0; fi log "Postgres not running — starting via devenv…" devenv up postgres & STARTED_PG=$! local i for ((i = 0; i < 45; i++)); do if pg_isready -h "$PGHOST" >/dev/null 2>&1; then log "Postgres ready" return 0 fi sleep 1 done die "Postgres did not become ready in 45s" } stop_postgres() { [[ -n "$STARTED_PG" ]] || return 0 log "stopping Postgres (started by this script)…" kill "$STARTED_PG" 2>/dev/null || true wait "$STARTED_PG" 2>/dev/null || true STARTED_PG="" } # ──────────────────────── VM management ──────────────────────────────── vm_ip() { incus list "$VM_NAME" -f csv -c 4 2>/dev/null | head -1 | awk '{print $1}'; } start_vm() { log "starting VM '$VM_NAME' (port $PORT)…" VM_NAME="$VM_NAME" \ MUSIC_SOURCE="$MUSIC_DIR" \ LISTEN_PORT="$PORT" \ RUST_LOG=debug \ bash "$SCRIPTS_DIR/vm.sh" up local ip; ip="$(vm_ip)" [[ -n "$ip" ]] || die "could not determine VM IP" log "VM IP: $ip" log "waiting for health check…" local i for ((i = 0; i < 45; i++)); do if grpcurl -plaintext "$ip:$PORT" grpc.health.v1.Health/Check 2>/dev/null | grep -q SERVING; then log "server healthy" VM_IP="$ip" return fi sleep 1 done die "server health check timed out" } destroy_vm() { VM_NAME="$VM_NAME" bash "$SCRIPTS_DIR/vm.sh" destroy 2>/dev/null || true } # ──────────────────────── DB management ──────────────────────────────── reset_db() { log "resetting database '$DB_NAME'…" dropdb --if-exists "$DB_NAME" 2>/dev/null || true createdb "$DB_NAME" psql -q -d "$DB_NAME" -f "$PROJECT_DIR/db/schema.sql" } drop_db() { dropdb --if-exists "$DB_NAME" 2>/dev/null || true } # ────────────────────── client lifecycle ─────────────────────────────── build_client() { log "building client…" (cd "$PROJECT_DIR" && cargo build --bin musicfs) } start_client() { mkdir -p "$MNT" "$LOG_DIR" local dsn="postgresql://fujin@localhost/$DB_NAME?host=$PGHOST" log "starting client (mountpoint $MNT)…" RUST_LOG="$RUST_LOG_VAL" \ "$PROJECT_DIR/target/debug/musicfs" \ --source "http://$VM_IP:$PORT" \ --mountpoint "$MNT" \ --database "$dsn" \ --log-dir "$LOG_DIR" & CLIENT_PID=$! wait_for_mount } wait_for_mount() { log "waiting for FUSE mount…" local i for ((i = 0; i < 60; i++)); do if [[ -d "$MNT/E2E Artist A" ]]; then log "mount ready" return fi if ! kill -0 "$CLIENT_PID" 2>/dev/null; then die "client process exited prematurely — check $LOG_DIR" fi sleep 1 done die "FUSE mount not ready after 60s" } stop_client() { [[ -n "$CLIENT_PID" ]] || return 0 kill "$CLIENT_PID" 2>/dev/null || true local i for ((i = 0; i < 10; i++)); do kill -0 "$CLIENT_PID" 2>/dev/null || break sleep 1 done kill -9 "$CLIENT_PID" 2>/dev/null || true wait "$CLIENT_PID" 2>/dev/null || true CLIENT_PID="" fusermount -u "$MNT" 2>/dev/null || true sleep 1 } # ─────────────────────────── cleanup ─────────────────────────────────── cleanup() { local rv=$? log "cleaning up…" stop_client || true if [[ "$KEEP" != "1" ]]; then drop_db destroy_vm stop_postgres || true rm -rf "$MNT" else log "KEEP=1 — VM, DB, and mount left intact for debugging" log " mountpoint: $MNT" log " VM IP: ${VM_IP:-}" log " logs: $LOG_DIR" fi exit $rv } trap cleanup EXIT # ─────────────────────────── paths ───────────────────────────────────── ALPHA="$MNT/E2E Artist A/E2E Album X/01 - Alpha.flac" BETA="$MNT/E2E Artist A/E2E Album X/02 - Beta.flac" GAMMA="$MNT/E2E Artist B/E2E Album Y/01 - Gamma.mp3" DELTA="$MNT/E2E Artist B/E2E Album Y/02 - Delta.mp3" # ════════════════════════════════════════════════════════════════════════ # TESTS # ════════════════════════════════════════════════════════════════════════ test_structural() { printf '\n\033[1;36m── structural ──\033[0m\n' local root; root="$(ls "$MNT" 2>/dev/null || true)" assert_contains "root has Artist A dir" "$root" "E2E Artist A" assert_contains "root has Artist B dir" "$root" "E2E Artist B" local album_a; album_a="$(ls "$MNT/E2E Artist A/E2E Album X" 2>/dev/null || true)" assert_contains "Album X has Alpha.flac" "$album_a" "01 - Alpha.flac" assert_contains "Album X has Beta.flac" "$album_a" "02 - Beta.flac" local album_b; album_b="$(ls "$MNT/E2E Artist B/E2E Album Y" 2>/dev/null || true)" assert_contains "Album Y has Gamma.mp3" "$album_b" "01 - Gamma.mp3" assert_contains "Album Y has Delta.mp3" "$album_b" "02 - Delta.mp3" local ftype; ftype="$(stat -c %F "$ALPHA" 2>/dev/null || echo "missing")" assert_eq "Alpha is regular file" "regular file" "$ftype" local fsize; fsize="$(stat -c %s "$ALPHA" 2>/dev/null || echo 0)" assert_gt "Alpha virtual size > 0" "$fsize" 0 local dtype; dtype="$(stat -c %F "$MNT/E2E Artist A" 2>/dev/null || echo "missing")" assert_eq "Artist A is directory" "directory" "$dtype" } test_cache() { printf '\n\033[1;36m── cache behavior ──\033[0m\n' ffprobe -hide_banner -v quiet -show_format "$BETA" >/dev/null 2>&1 || true ffprobe -hide_banner -v quiet -show_format "$BETA" >/dev/null 2>&1 || true sleep 1 local log_file="" log_file="$(ls -t "$LOG_DIR"/musicfs.*.log 2>/dev/null | head -1)" || true local logs="" if [[ -n "$log_file" ]]; then logs="$(cat "$log_file" 2>/dev/null || true)" fi assert_contains "cache miss on first read" "$logs" "cache miss" assert_contains "cache hit on second read" "$logs" "cache hit" } test_flac_read() { printf '\n\033[1;36m── FLAC read ──\033[0m\n' local title; title="$(metaflac --show-tag=TITLE "$ALPHA" 2>/dev/null || true)" local artist; artist="$(metaflac --show-tag=ARTIST "$ALPHA" 2>/dev/null || true)" local album; album="$(metaflac --show-tag=ALBUM "$ALPHA" 2>/dev/null || true)" assert_contains "FLAC TITLE tag" "$title" "TITLE=Alpha" assert_contains "FLAC ARTIST tag" "$artist" "ARTIST=E2E Artist A" assert_contains "FLAC ALBUM tag" "$album" "ALBUM=E2E Album X" local pics; pics="$(metaflac --list --block-type=PICTURE "$ALPHA" 2>/dev/null || true)" assert_contains "FLAC has cover art" "$pics" "PICTURE" } test_flac_write() { printf '\n\033[1;36m── FLAC write ──\033[0m\n' metaflac --remove-tag=ARTIST --set-tag="ARTIST=Modified FLAC" "$BETA" 2>/dev/null local artist; artist="$(metaflac --show-tag=ARTIST "$BETA" 2>/dev/null || true)" assert_contains "FLAC write — ARTIST updated" "$artist" "ARTIST=Modified FLAC" local title; title="$(metaflac --show-tag=TITLE "$BETA" 2>/dev/null || true)" assert_contains "FLAC write — TITLE preserved" "$title" "TITLE=Beta" } test_mp3_read() { printf '\n\033[1;36m── MP3 read ──\033[0m\n' local info; info="$(id3v2 --list "$GAMMA" 2>/dev/null || true)" assert_contains "MP3 TITLE tag" "$info" "Gamma" assert_contains "MP3 ARTIST tag" "$info" "E2E Artist B" assert_contains "MP3 ALBUM tag" "$info" "E2E Album Y" } test_mp3_write() { printf '\n\033[1;36m── MP3 write ──\033[0m\n' id3v2 --song "Modified Delta" --artist "Modified MP3" "$DELTA" 2>/dev/null local info; info="$(id3v2 --list "$DELTA" 2>/dev/null || true)" assert_contains "MP3 write — TIT2 updated" "$info" "Modified Delta" assert_contains "MP3 write — TPE1 updated" "$info" "Modified MP3" } test_chunked_write() { printf '\n\033[1;36m── chunked write resilience ──\033[0m\n' # Write at offset 8192 — well past any metadata blocks for both formats. # The write handler should ignore it (no tag pattern match) and the # metadata structure remains parseable. local title_before; title_before="$(metaflac --show-tag=TITLE "$ALPHA" 2>/dev/null || true)" printf 'XXXX' | dd of="$ALPHA" bs=1 seek=8192 count=4 conv=notrunc 2>/dev/null || true sleep 0.5 local title_after; title_after="$(metaflac --show-tag=TITLE "$ALPHA" 2>/dev/null || true)" assert_eq "FLAC tags unchanged after non-metadata write" "$title_before" "$title_after" local mp3_before; mp3_before="$(id3v2 --list "$GAMMA" 2>/dev/null | grep -i 'title' || true)" printf 'YYYY' | dd of="$GAMMA" bs=1 seek=8192 count=4 conv=notrunc 2>/dev/null || true sleep 0.5 local mp3_after; mp3_after="$(id3v2 --list "$GAMMA" 2>/dev/null | grep -i 'title' || true)" assert_eq "MP3 tags unchanged after non-metadata write" "$mp3_before" "$mp3_after" } test_persistence() { printf '\n\033[1;36m── persistence (client restart) ──\033[0m\n' metaflac --remove-tag=ARTIST --set-tag="ARTIST=Persisted Artist" "$ALPHA" 2>/dev/null local before; before="$(metaflac --show-tag=ARTIST "$ALPHA" 2>/dev/null || true)" assert_contains "persistence — write before restart" "$before" "ARTIST=Persisted Artist" log "restarting client…" stop_client start_client local after; after="$(metaflac --show-tag=ARTIST "$ALPHA" 2>/dev/null || true)" assert_contains "persistence — read after restart" "$after" "ARTIST=Persisted Artist" } # ════════════════════════════════════════════════════════════════════════ # MAIN # ════════════════════════════════════════════════════════════════════════ main() { [[ -n "${PGHOST:-}" ]] || die "PGHOST not set — run inside devenv shell" ensure_postgres command -v incus >/dev/null || die "incus not found" command -v grpcurl >/dev/null || die "grpcurl not found" command -v metaflac >/dev/null || die "metaflac not found" command -v id3v2 >/dev/null || die "id3v2 not found" command -v ffprobe >/dev/null || die "ffprobe not found" log "=== E2E suite start ===" # Phase 0: fixtures bash "$SCRIPT_DIR/make-fixtures.sh" "$MUSIC_DIR" # Phase 1: server start_vm # Phase 2: client build_client reset_db start_client # Phase 3-8: tests (read-only first, mutating last) test_structural test_cache test_flac_read test_flac_write test_mp3_read test_mp3_write test_chunked_write test_persistence # Summary printf '\n' summary local rv=$? log "=== E2E suite done ===" exit $rv } main "$@"