mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
test: add E2E smoke test script + CI gate
After deploying the binary against a fresh data dir and exercising every
major REST endpoint by hand, codify the verification as a re-runnable
script.
scripts/smoke-test.sh:
- Spins up bin/mediastation-go against a tempdir.
- Generates 3 sample clips (movie, tv, anime) + an external SRT.
- Walks 26 assertions: health → auth → libraries → scan → ffprobe →
season parser → search → range stream → HLS playlist → external
subtitle → history → favourites → playlists → RBAC (alice 403) →
NFO export → recycle bin → SPA fallback → graceful shutdown.
- Tears the server down via SIGTERM and reports pass/fail counts.
- Exits non-zero on any failure so CI can use it as a deployment gate.
Local verification on this branch: PASS=26 FAIL=0.
Wiring:
- Makefile: new 'smoke' target, depends on 'build'.
- .github/workflows/ci.yml: third job 'smoke' that depends on backend
+ frontend, installs ffmpeg, builds both halves, then runs the
script. The CI pipeline now blocks merges that break end-to-end
deployability.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
Executable
+241
@@ -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))
|
||||
Reference in New Issue
Block a user