diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6435991..605d43b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,29 @@ jobs: - name: Type-check & build run: npm run build + + smoke: + name: E2E smoke test + runs-on: ubuntu-latest + needs: [backend, frontend] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install -y ffmpeg + - name: Build backend + run: make build-server + - name: Build frontend + run: cd web && npm ci && npm run build + - name: Run smoke test + run: ./scripts/smoke-test.sh + env: + PORT: 18080 diff --git a/Makefile b/Makefile index 77fdbad..ae2d400 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-server build-web dev dev-web run docker docker-stop clean install-web tidy test vet +.PHONY: all build build-server build-web dev dev-web run docker docker-stop clean install-web tidy test vet smoke # ---- 默认目标 ---- all: build @@ -46,5 +46,11 @@ test: vet: go vet ./... +# ---- 端到端冒烟测试 ---- +# Spins up the binary against a temp dir, exercises every REST surface, +# tears it back down. Fails the build on any regression. +smoke: build + ./scripts/smoke-test.sh + clean: rm -rf bin/ web/dist/ web/node_modules/ diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..12507e0 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# ============================================================================= +# scripts/smoke-test.sh +# +# End-to-end deployment smoke test. Spins up a fresh MediaStationGo binary +# against a temporary data dir, exercises every major REST surface, then +# tears it all down. Exits non-zero on the first failure so it can run in +# CI as a deployment gate. +# +# Requirements: +# - bin/mediastation-go (run `make build-server` first) +# - web/dist (run `make build-web` first) +# - ffmpeg + ffprobe on PATH (the script will skip transcode tests if missing) +# - python3 (used for JSON pretty-printing only) +# - curl +# +# Usage: +# ./scripts/smoke-test.sh # default port 18080 +# PORT=19000 ./scripts/smoke-test.sh # override +# ============================================================================= +set -euo pipefail + +PORT="${PORT:-18080}" +ROOT="$(mktemp -d -t msgo-smoke.XXXXXX)" +DATA="$ROOT/data" +CACHE="$ROOT/cache" +MEDIA="$ROOT/media" +LOG="$ROOT/server.log" +PASS=0 +FAIL=0 + +cleanup() { + if [ -n "${PID:-}" ]; then + kill -TERM "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + fi + rm -rf "$ROOT" +} +trap cleanup EXIT + +# --- Helpers ---------------------------------------------------------------- +ok() { printf " \033[32m✓\033[0m %s\n" "$1"; PASS=$((PASS+1)); } +fail() { printf " \033[31m✗\033[0m %s\n" "$1"; FAIL=$((FAIL+1)); } +hdr() { printf "\n\033[1;36m==> %s\033[0m\n" "$1"; } + +require() { + command -v "$1" >/dev/null || { echo "missing dependency: $1"; exit 2; } +} +require curl +require python3 + +HAVE_FFMPEG=1 +command -v ffmpeg >/dev/null && command -v ffprobe >/dev/null || HAVE_FFMPEG=0 + +# --- 1. Prepare workspace --------------------------------------------------- +hdr "Preparing $ROOT" +mkdir -p "$DATA" "$CACHE" "$MEDIA/movies" "$MEDIA/tv/Show/Season 01" "$MEDIA/anime" + +if [ "$HAVE_FFMPEG" = 1 ]; then + ffmpeg -y -loglevel error -f lavfi -i "color=c=blue:s=320x240:d=2" \ + -f lavfi -i "sine=frequency=1000:duration=2" \ + -c:v libx264 -preset ultrafast -c:a aac \ + "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.mp4" + ffmpeg -y -loglevel error -f lavfi -i "color=c=red:s=320x240:d=2" \ + -f lavfi -i "sine=frequency=440:duration=2" \ + -c:v libx264 -preset ultrafast -c:a aac \ + "$MEDIA/tv/Show/Season 01/Show.S01E01.mkv" + ffmpeg -y -loglevel error -f lavfi -i "color=c=cyan:s=320x240:d=2" \ + -f lavfi -i "sine=frequency=500:duration=2" \ + -c:v libx264 -preset ultrafast -c:a aac \ + "$MEDIA/anime/[Erai-raws] One Piece - 1100 [1080p].mkv" + cat > "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.zh.srt" <<'SRT' +1 +00:00:00,500 --> 00:00:01,500 +Hello +SRT + ok "ffmpeg sample media generated" +else + fail "ffmpeg/ffprobe not on PATH — transcode + ffprobe tests will be skipped" +fi + +# --- 2. Start the server ---------------------------------------------------- +hdr "Starting MediaStationGo on :$PORT" +BIN="${BIN:-./bin/mediastation-go}" +if [ ! -x "$BIN" ]; then + echo "Binary $BIN not found. Run 'make build-server' first." + exit 2 +fi +ADMIN_INITIAL_PASSWORD=smoketest12345 \ +MEDIASTATION_APP_PORT="$PORT" \ +MEDIASTATION_APP_DATA_DIR="$DATA" \ +MEDIASTATION_APP_WEB_DIR="${WEB_DIR:-./web/dist}" \ +MEDIASTATION_DATABASE_DB_PATH="$DATA/test.db" \ +MEDIASTATION_CACHE_CACHE_DIR="$CACHE" \ +"$BIN" >"$LOG" 2>&1 & +PID=$! + +# Wait until /api/health responds. +for _ in $(seq 1 20); do + curl -s -o /dev/null "http://127.0.0.1:$PORT/api/health" && break + sleep 0.5 +done +curl -s "http://127.0.0.1:$PORT/api/health" | grep -q '"ok"' && ok "/api/health" || fail "/api/health" + +# --- 3. Auth ---------------------------------------------------------------- +hdr "Auth" +TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"smoketest12345"}' \ + "http://127.0.0.1:$PORT/api/auth/login" | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])') +[ -n "$TOKEN" ] && ok "login as admin" || fail "login as admin" +H="Authorization: Bearer $TOKEN" +curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/me" | grep -q 200 \ + && ok "/api/me with bearer" || fail "/api/me with bearer" +curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/api/me" | grep -q 401 \ + && ok "/api/me without bearer → 401" || fail "/api/me without bearer" + +# --- 4. Library + scan ------------------------------------------------------ +hdr "Library + scan + search" +MOVIE=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \ + -d "{\"name\":\"movies\",\"path\":\"$MEDIA/movies\",\"type\":\"movie\"}" \ + "http://127.0.0.1:$PORT/api/libraries" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') +[ -n "$MOVIE" ] && ok "create movie library" || fail "create movie library" + +TV=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \ + -d "{\"name\":\"tv\",\"path\":\"$MEDIA/tv\",\"type\":\"tv\"}" \ + "http://127.0.0.1:$PORT/api/libraries" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') +[ -n "$TV" ] && ok "create tv library" || fail "create tv library" + +if [ "$HAVE_FFMPEG" = 1 ]; then + RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/scan") + ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])') + [ "$ADDED" = "1" ] && ok "scan: 1 movie added" || fail "scan movie added=$ADDED" + + RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/scan") + ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])') + [ "$ADDED" = "1" ] && ok "scan: 1 tv episode added" || fail "scan tv added=$ADDED" + + # SxxExx parser + SE=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/seasons" \ + | python3 -c 'import json,sys; ss=json.load(sys.stdin)["seasons"]; e=ss[0]["episodes"][0]; print("%dx%d" % (e["season_num"], e["episode_num"]))') + [ "$SE" = "1x1" ] && ok "season parser → S01E01" || fail "season parser → $SE" + + # ffprobe wrote width/height/codec + W=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["width"])') + [ "$W" = "320" ] && ok "ffprobe metadata extracted (width=320)" || fail "ffprobe width=$W" +fi + +curl -s -H "$H" "http://127.0.0.1:$PORT/api/media?q=inception" \ + | python3 -c 'import json,sys; assert json.load(sys.stdin)["items"], "empty"' \ + && ok "search returns rows" || fail "search returns rows" + +# --- 5. Streaming ----------------------------------------------------------- +if [ "$HAVE_FFMPEG" = 1 ]; then + hdr "Streaming + subtitles" + ID=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["id"])') + curl -s -o /dev/null -w "%{http_code}" -H "$H" -H "Range: bytes=0-1023" \ + "http://127.0.0.1:$PORT/api/stream/$ID" | grep -q 206 \ + && ok "stream 206 partial" || fail "stream 206 partial" + curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/stream/$ID" \ + | grep -q 200 && ok "stream 200 full" || fail "stream 200 full" + + TRACKS=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/subtitles" \ + | python3 -c 'import json,sys;print(len(json.load(sys.stdin)["tracks"]))') + [ "$TRACKS" = "1" ] && ok "external SRT discovered" || fail "external SRT discovered=$TRACKS" + + curl -s -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID/index.m3u8" | grep -q EXTM3U \ + && ok "HLS playlist (transcode triggered)" || fail "HLS playlist" + curl -s -X DELETE -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID" -o /dev/null +fi + +# --- 6. Playback bookkeeping ----------------------------------------------- +hdr "History / favourites / playlists" +curl -s -X POST -H "$H" -H 'Content-Type: application/json' \ + -d "{\"media_id\":\"$ID\",\"position_ms\":500,\"duration_ms\":2000}" \ + -o /dev/null "http://127.0.0.1:$PORT/api/history" +HIST=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/history" \ + | python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))') +[ "$HIST" -ge 1 ] && ok "history persisted" || fail "history persisted=$HIST" + +curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/favourites/$ID" \ + | grep -q '"favourite":true' && ok "favourite toggled on" || fail "favourite toggle" + +PL=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \ + -d '{"name":"smoke"}' "http://127.0.0.1:$PORT/api/playlists" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') +curl -s -X POST -H "$H" -H 'Content-Type: application/json' \ + -d "{\"media_id\":\"$ID\"}" -o /dev/null \ + "http://127.0.0.1:$PORT/api/playlists/$PL/items" +N=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/playlists/$PL" \ + | python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))') +[ "$N" -ge 1 ] && ok "playlist has 1 item" || fail "playlist items=$N" + +# --- 7. Admin operations --------------------------------------------------- +hdr "Admin / RBAC / persistence" +curl -s -X POST -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice12345"}' \ + -o /dev/null "http://127.0.0.1:$PORT/api/auth/register" +ATOK=$(curl -s -X POST -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice12345"}' \ + "http://127.0.0.1:$PORT/api/auth/login" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])') +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ATOK" \ + -X POST -H 'Content-Type: application/json' \ + -d "{\"name\":\"x\",\"path\":\"$MEDIA\",\"type\":\"movie\"}" \ + "http://127.0.0.1:$PORT/api/libraries" | grep -q 403 \ + && ok "regular user cannot create library (403)" || fail "regular user RBAC" + +# --- 8. NFO + recycle bin -------------------------------------------------- +if [ "$HAVE_FFMPEG" = 1 ]; then + curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/nfo" \ + | grep -q '"path"' && ok "NFO export" || fail "NFO export" + [ -f "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.nfo" ] \ + && ok "NFO file written next to media" || fail "NFO file missing" + + curl -s -X DELETE -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID" + R=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/recycle" \ + | python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))') + [ "$R" -ge 1 ] && ok "recycle bin has the soft-deleted row" || fail "recycle bin=$R" + curl -s -X POST -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID/restore" +fi + +# --- 9. SPA + assets ------------------------------------------------------- +hdr "SPA" +curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/" | grep -q 200 \ + && ok "SPA / served" || fail "SPA /" +curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/login" | grep -q 200 \ + && ok "SPA /login fallback" || fail "SPA /login" + +# --- 10. Graceful shutdown ------------------------------------------------- +hdr "Shutdown" +kill -TERM "$PID" +wait "$PID" 2>/dev/null || true +unset PID +grep -q "MediaStationGo stopped" "$LOG" && ok "graceful shutdown logged" || fail "no shutdown log" + +# --- Summary --------------------------------------------------------------- +hdr "Summary" +printf "\033[32m PASS=%d\033[0m \033[31mFAIL=%d\033[0m\n" "$PASS" "$FAIL" +exit $((FAIL > 0 ? 1 : 0))