Implement e2e, add container with server

This commit is contained in:
Alexander
2026-07-01 00:32:47 +02:00
parent 601c466ce4
commit d001e81128
19 changed files with 968 additions and 85 deletions
+51
View File
@@ -0,0 +1,51 @@
# musicfs E2E Tests
Full distributed-path tests: server in Incus VM, local FUSE client, tests run
against the mountpoint.
## Prerequisites
- Inside `devenv shell` (provides ffmpeg, metaflac, id3v2, incus, grpcurl, Postgres)
- Postgres running (`devenv up postgres`)
- Incus installed and configured
## Run
```sh
just e2e # full suite (creates VM, runs tests, destroys VM)
KEEP=1 just e2e # keep VM + DB alive after run for debugging
```
## What it tests
| Phase | Description |
| ------------ | ------------------------------------------------------- |
| structural | Virtual directory tree matches fixture tags |
| cache | First read = cache miss, second read = cache hit |
| FLAC read | metaflac reads tags through FUSE reassembly |
| FLAC write | metaflac writes vorbis comment, write handler persists |
| MP3 read | id3v2 reads ID3v2 tags through FUSE reassembly |
| MP3 write | id3v2 writes tags, write handler persists |
| chunked write| Non-metadata writes don't corrupt tag structure |
| persistence | Tags survive client restart (DB restore path) |
## Fixtures
`make-fixtures.sh` generates a deterministic library under `target/e2e/music/`:
```
E2E Artist A/E2E Album X/01 - Alpha.flac (with cover art)
E2E Artist A/E2E Album X/02 - Beta.flac
E2E Artist B/E2E Album Y/01 - Gamma.mp3 (with cover art)
E2E Artist B/E2E Album Y/02 - Delta.mp3
```
All tags are known at test time → exact assertions.
## Profiles
```sh
just up e2e # start client against e2e VM (manual testing)
```
Client uses `musicfs_e2e` database and `./logs/e2e` for logs.
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# lib.sh — shared helpers for E2E test suite.
#
# Test functions set a global $FAILURES counter and $FAILED list so the
# caller can exit non-zero after all suites have run.
#
# Sourced by run.sh — do NOT execute directly.
#
FAILURES=0
PASSES=0
FAILED_TESTS=""
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
printf ' \033[1;32mPASS\033[0m %s\n' "$desc"
((++PASSES))
else
printf ' \033[1;31mFAIL\033[0m %s\n' "$desc"
printf ' expected: %q\n' "$expected"
printf ' actual: %q\n' "$actual"
((++FAILURES))
FAILED_TESTS+="$desc; "
fi
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" == *"$needle"* ]]; then
printf ' \033[1;32mPASS\033[0m %s\n' "$desc"
((++PASSES))
else
printf ' \033[1;31mFAIL\033[0m %s\n' "$desc"
printf ' haystack did not contain: %q\n' "$needle"
((++FAILURES))
FAILED_TESTS+="$desc; "
fi
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" != *"$needle"* ]]; then
printf ' \033[1;32mPASS\033[0m %s\n' "$desc"
((++PASSES))
else
printf ' \033[1;31mFAIL\033[0m %s\n' "$desc"
printf ' haystack unexpectedly contained: %q\n' "$needle"
((++FAILURES))
FAILED_TESTS+="$desc; "
fi
}
assert_match() {
local desc="$1" haystack="$2" regex="$3"
if [[ "$haystack" =~ $regex ]]; then
printf ' \033[1;32mPASS\033[0m %s\n' "$desc"
((++PASSES))
else
printf ' \033[1;31mFAIL\033[0m %s\n' "$desc"
printf ' did not match regex: %s\n' "$regex"
((++FAILURES))
FAILED_TESTS+="$desc; "
fi
}
assert_file_exists() {
local desc="$1" path="$2"
if [[ -e "$path" ]]; then
printf ' \033[1;32mPASS\033[0m %s\n' "$desc"
((++PASSES))
else
printf ' \033[1;31mFAIL\033[0m %s\n' "$desc"
printf ' file not found: %s\n' "$path"
((++FAILURES))
FAILED_TESTS+="$desc; "
fi
}
assert_gt() {
local desc="$1" a="$2" b="$3"
if (( a > b )); then
printf ' \033[1;32mPASS\033[0m %s (%s > %s)\n' "$desc" "$a" "$b"
((++PASSES))
else
printf ' \033[1;31mFAIL\033[0m %s (%s <= %s)\n' "$desc" "$a" "$b"
((++FAILURES))
FAILED_TESTS+="$desc; "
fi
}
summary() {
echo
printf -- '---\n'
printf 'passes: %d\n' "$PASSES"
printf 'failures: %d\n' "$FAILURES"
if [[ -n "$FAILED_TESTS" ]]; then
printf 'failed: %s\n' "$FAILED_TESTS"
fi
if (( FAILURES > 0 )); then
return 1
fi
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# make-fixtures.sh — generate a deterministic music library for E2E tests.
#
# Creates 2 FLAC + 2 MP3 files with known tags and embedded cover art under
# target/e2e/music/. The tags drive the FUSE virtual directory layout, so
# the directory names below are also the expected mount-point paths.
#
set -euo pipefail
MUSIC_DIR="${1:-$(pwd)/target/e2e/music}"
log() { printf '\033[1;34m[fixtures]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[fixtures error]\033[0m %s\n' "$*" >&2; exit 1; }
command -v ffmpeg >/dev/null || die "ffmpeg not found (run inside devenv shell)"
command -v metaflac >/dev/null || die "metaflac not found (run inside devenv shell)"
rm -rf "$MUSIC_DIR"
mkdir -p "$MUSIC_DIR"
# ── cover art ──────────────────────────────────────────────────────────
COVER="$MUSIC_DIR/.cover.png"
COVER2="$MUSIC_DIR/.cover2.png"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "color=c=0x884422:s=200x200:d=0.04" -frames:v 1 "$COVER"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "color=c=0x224488:s=200x200:d=0.04" -frames:v 1 "$COVER2"
# ── FLAC: E2E Artist A / E2E Album X ──────────────────────────────────
FLAC_DIR="$MUSIC_DIR/E2E Artist A/E2E Album X"
mkdir -p "$FLAC_DIR"
log "generating FLAC Alpha …"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "sine=frequency=440:duration=3" -c:a flac \
-metadata title="Alpha" \
-metadata artist="E2E Artist A" \
-metadata album_artist="E2E Artist A" \
-metadata album="E2E Album X" \
-metadata tracknumber=1 \
-metadata date=2024 \
"$FLAC_DIR/01 - Alpha.flac"
metaflac --import-picture-from="3||||$COVER" "$FLAC_DIR/01 - Alpha.flac"
log "generating FLAC Beta …"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "sine=frequency=330:duration=3" -c:a flac \
-metadata title="Beta" \
-metadata artist="E2E Artist A" \
-metadata album_artist="E2E Artist A" \
-metadata album="E2E Album X" \
-metadata tracknumber=2 \
-metadata date=2024 \
"$FLAC_DIR/02 - Beta.flac"
# ── MP3: E2E Artist B / E2E Album Y ───────────────────────────────────
MP3_DIR="$MUSIC_DIR/E2E Artist B/E2E Album Y"
mkdir -p "$MP3_DIR"
log "generating MP3 Gamma …"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "sine=frequency=392:duration=3" -i "$COVER2" \
-c:a libmp3lame -q:a 4 -id3v2_version 3 \
-metadata title="Gamma" \
-metadata artist="E2E Artist B" \
-metadata album_artist="E2E Artist B" \
-metadata album="E2E Album Y" \
-metadata tracknumber=1 \
-metadata date=2023 \
-map 0:a -map 1:v -disposition:v attached_pic \
"$MP3_DIR/01 - Gamma.mp3"
log "generating MP3 Delta …"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "sine=frequency=294:duration=3" \
-c:a libmp3lame -q:a 4 -id3v2_version 3 \
-metadata title="Delta" \
-metadata artist="E2E Artist B" \
-metadata album_artist="E2E Artist B" \
-metadata album="E2E Album Y" \
-metadata tracknumber=2 \
-metadata date=2023 \
"$MP3_DIR/02 - Delta.mp3"
rm -f "$COVER" "$COVER2"
log "done — 4 files under $MUSIC_DIR"
+358
View File
@@ -0,0 +1,358 @@
#!/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:-<unknown>}"
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 "$@"