Initial release v1.0.0
This commit is contained in:
132
docs/scripts/predeploy-test.sh
Normal file
132
docs/scripts/predeploy-test.sh
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# One-command predeploy test runner for Lidify.
|
||||
#
|
||||
# What it does:
|
||||
# - Starts a clean docker compose stack (core services only)
|
||||
# - Runs backend API smoke tests
|
||||
# - Runs frontend Playwright E2E smoke tests
|
||||
# - Optionally tears the stack down
|
||||
#
|
||||
# Requirements:
|
||||
# - Docker + docker compose plugin
|
||||
# - Node/npm available (to run the test runners)
|
||||
# - A MUSIC_PATH that contains at least one track if you want playback/playlist tests to pass
|
||||
#
|
||||
# Environment variables:
|
||||
# - LIDIFY_UI_BASE_URL (default: http://127.0.0.1:3030)
|
||||
# - LIDIFY_API_BASE_URL (default: http://127.0.0.1:3006)
|
||||
# - LIDIFY_TEST_USERNAME (default: predeploy)
|
||||
# - LIDIFY_TEST_PASSWORD (default: predeploy-password)
|
||||
# - LIDIFY_COMPOSE_FILE (default: docker-compose.yml)
|
||||
# - LIDIFY_COMPOSE_PROJECT (default: lidify_predeploy_<timestamp>)
|
||||
# - LIDIFY_TEARDOWN (default: 1) set to 0 to keep containers running
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
COMPOSE_FILE="${LIDIFY_COMPOSE_FILE:-docker-compose.yml}"
|
||||
UI_BASE_URL="${LIDIFY_UI_BASE_URL:-http://127.0.0.1:3030}"
|
||||
API_BASE_URL="${LIDIFY_API_BASE_URL:-http://127.0.0.1:3006}"
|
||||
TEARDOWN="${LIDIFY_TEARDOWN:-1}"
|
||||
|
||||
PROJECT="${LIDIFY_COMPOSE_PROJECT:-lidify_predeploy_$(date +%Y%m%d_%H%M%S)}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[predeploy] project=$PROJECT"
|
||||
echo "[predeploy] compose=$COMPOSE_FILE"
|
||||
echo "[predeploy] ui=$UI_BASE_URL"
|
||||
echo "[predeploy] api=$API_BASE_URL"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[predeploy] ERROR: docker is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "[predeploy] ERROR: docker compose plugin not available (try: docker --version, docker compose version)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [ "$TEARDOWN" = "1" ]; then
|
||||
echo "[predeploy] tearing down docker compose stack..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v
|
||||
else
|
||||
echo "[predeploy] teardown disabled (LIDIFY_TEARDOWN=0) - leaving containers running"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[predeploy] starting docker compose (core services only)..."
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d postgres redis backend frontend
|
||||
|
||||
echo "[predeploy] waiting for backend health..."
|
||||
node - <<'NODE'
|
||||
const base = (process.env.LIDIFY_API_BASE_URL || "http://127.0.0.1:3006").replace(/\/$/, "");
|
||||
const timeoutMs = 120000;
|
||||
const start = Date.now();
|
||||
|
||||
async function sleep(ms){ return new Promise(r=>setTimeout(r, ms)); }
|
||||
|
||||
(async () => {
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${base}/health`);
|
||||
if (res.ok) process.exit(0);
|
||||
} catch {}
|
||||
await sleep(1000);
|
||||
}
|
||||
console.error(`Backend did not become healthy at ${base}/health within ${timeoutMs}ms`);
|
||||
process.exit(1);
|
||||
})();
|
||||
NODE
|
||||
|
||||
echo "[predeploy] waiting for frontend health..."
|
||||
node - <<'NODE'
|
||||
const base = (process.env.LIDIFY_UI_BASE_URL || "http://127.0.0.1:3030").replace(/\/$/, "");
|
||||
const timeoutMs = 120000;
|
||||
const start = Date.now();
|
||||
|
||||
async function sleep(ms){ return new Promise(r=>setTimeout(r, ms)); }
|
||||
|
||||
(async () => {
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${base}/health`);
|
||||
if (res.ok) process.exit(0);
|
||||
} catch {}
|
||||
await sleep(1000);
|
||||
}
|
||||
console.error(`Frontend did not become healthy at ${base}/health within ${timeoutMs}ms`);
|
||||
process.exit(1);
|
||||
})();
|
||||
NODE
|
||||
|
||||
echo "[predeploy] running backend API smoke tests..."
|
||||
(cd backend && \
|
||||
LIDIFY_API_BASE_URL="$API_BASE_URL" \
|
||||
LIDIFY_TEST_USERNAME="${LIDIFY_TEST_USERNAME:-predeploy}" \
|
||||
LIDIFY_TEST_PASSWORD="${LIDIFY_TEST_PASSWORD:-predeploy-password}" \
|
||||
npm run test:smoke)
|
||||
|
||||
echo "[predeploy] ensuring Playwright browser is installed..."
|
||||
(cd frontend && npx playwright install chromium)
|
||||
|
||||
echo "[predeploy] running frontend E2E smoke tests..."
|
||||
(cd frontend && \
|
||||
LIDIFY_UI_BASE_URL="$UI_BASE_URL" \
|
||||
LIDIFY_TEST_USERNAME="${LIDIFY_TEST_USERNAME:-predeploy}" \
|
||||
LIDIFY_TEST_PASSWORD="${LIDIFY_TEST_PASSWORD:-predeploy-password}" \
|
||||
npm run test:e2e)
|
||||
|
||||
echo "[predeploy] PASS"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
17
docs/scripts/reset-analysis-for-new-moods.sql
Normal file
17
docs/scripts/reset-analysis-for-new-moods.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Reset all enhanced tracks for re-analysis to populate new mood fields
|
||||
-- (moodParty, moodAcoustic, moodElectronic)
|
||||
|
||||
-- Option 1: Reset only enhanced tracks (faster - already have ML models loaded)
|
||||
UPDATE "Track"
|
||||
SET
|
||||
"analysisStatus" = 'pending',
|
||||
"moodParty" = NULL,
|
||||
"moodAcoustic" = NULL,
|
||||
"moodElectronic" = NULL
|
||||
WHERE "analysisMode" = 'enhanced';
|
||||
|
||||
-- Check how many tracks will be re-analyzed
|
||||
SELECT COUNT(*) as tracks_to_reanalyze FROM "Track" WHERE "analysisStatus" = 'pending';
|
||||
|
||||
|
||||
|
||||
222
docs/scripts/smoke.ts
Normal file
222
docs/scripts/smoke.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Lidify predeploy smoke tests (API-level).
|
||||
*
|
||||
* Goals:
|
||||
* - deterministic, fast "is the app basically working?" checks
|
||||
* - no build step (runs via tsx)
|
||||
*
|
||||
* Usage:
|
||||
* LIDIFY_API_BASE_URL=http://127.0.0.1:3006 \
|
||||
* LIDIFY_TEST_USERNAME=predeploy \
|
||||
* LIDIFY_TEST_PASSWORD=predeploy-password \
|
||||
* npm run test:smoke
|
||||
*/
|
||||
|
||||
type Json = any;
|
||||
|
||||
const API_BASE_URL = (process.env.LIDIFY_API_BASE_URL || "http://127.0.0.1:3006").replace(/\/$/, "");
|
||||
const USERNAME = process.env.LIDIFY_TEST_USERNAME || "predeploy";
|
||||
const PASSWORD = process.env.LIDIFY_TEST_PASSWORD || "predeploy-password";
|
||||
|
||||
const WAIT_MS = Number(process.env.LIDIFY_SMOKE_WAIT_MS || "60000"); // total budget
|
||||
const POLL_INTERVAL_MS = Number(process.env.LIDIFY_SMOKE_POLL_INTERVAL_MS || "1000");
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function assert(condition: any, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
path: string,
|
||||
opts: RequestInit & { token?: string } = {}
|
||||
): Promise<{ status: number; ok: boolean; json: Json }> {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(opts.headers as any),
|
||||
};
|
||||
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
||||
|
||||
const res = await fetch(url, { ...opts, headers });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
return { status: res.status, ok: res.ok, json };
|
||||
}
|
||||
|
||||
async function waitForHealth() {
|
||||
const start = Date.now();
|
||||
let lastErr: any = null;
|
||||
|
||||
while (Date.now() - start < WAIT_MS) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/health`);
|
||||
if (res.ok) return;
|
||||
lastErr = new Error(`health returned ${res.status}`);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Backend did not become healthy at ${API_BASE_URL}/health within ${WAIT_MS}ms. Last error: ${
|
||||
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureTestUserAndToken(): Promise<string> {
|
||||
// Prefer onboarding/register because it's available without admin and works even when users exist.
|
||||
const register = await fetchJson("/api/onboarding/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
||||
});
|
||||
|
||||
if (register.ok && register.json?.token) {
|
||||
return register.json.token as string;
|
||||
}
|
||||
|
||||
// If user already exists, login.
|
||||
const login = await fetchJson("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
||||
});
|
||||
assert(login.ok, `Login failed: status=${login.status} body=${JSON.stringify(login.json)}`);
|
||||
assert(login.json?.token, `Login did not return token: ${JSON.stringify(login.json)}`);
|
||||
return login.json.token as string;
|
||||
}
|
||||
|
||||
async function completeOnboarding(token: string) {
|
||||
const res = await fetchJson("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
// It's fine if it's already complete; endpoint should still succeed.
|
||||
assert(res.ok, `Onboarding complete failed: status=${res.status} body=${JSON.stringify(res.json)}`);
|
||||
}
|
||||
|
||||
async function getOneTrackId(token: string): Promise<string | null> {
|
||||
const tracks = await fetchJson("/api/library/tracks?limit=1&offset=0", { method: "GET", token });
|
||||
assert(tracks.ok, `Fetch tracks failed: status=${tracks.status} body=${JSON.stringify(tracks.json)}`);
|
||||
const id = tracks.json?.tracks?.[0]?.id;
|
||||
return typeof id === "string" ? id : null;
|
||||
}
|
||||
|
||||
async function scanLibraryIfNeeded(token: string) {
|
||||
// If you already have at least one track, don’t force a scan (keeps it fast).
|
||||
const existing = await getOneTrackId(token);
|
||||
if (existing) return;
|
||||
|
||||
const scan = await fetchJson("/api/library/scan", { method: "POST", token });
|
||||
assert(scan.ok, `Library scan start failed: status=${scan.status} body=${JSON.stringify(scan.json)}`);
|
||||
const jobId = scan.json?.jobId;
|
||||
assert(typeof jobId === "string", `Library scan did not return jobId: ${JSON.stringify(scan.json)}`);
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WAIT_MS) {
|
||||
const status = await fetchJson(`/api/library/scan/status/${jobId}`, { method: "GET", token });
|
||||
assert(status.ok, `Library scan status failed: status=${status.status} body=${JSON.stringify(status.json)}`);
|
||||
const s = status.json?.status;
|
||||
if (s === "completed" || s === "complete" || s === "done" || s === "success") return;
|
||||
if (s === "failed" || s === "error") {
|
||||
throw new Error(`Library scan failed: ${JSON.stringify(status.json)}`);
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`Library scan did not complete within ${WAIT_MS}ms (jobId=${jobId}).`);
|
||||
}
|
||||
|
||||
async function playlistsCrud(token: string) {
|
||||
// Needs at least one track.
|
||||
const trackId = await getOneTrackId(token);
|
||||
assert(
|
||||
trackId,
|
||||
`No tracks found. Set MUSIC_PATH to a library with at least one track, or run a scan before testing.`
|
||||
);
|
||||
|
||||
const created = await fetchJson("/api/playlists", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ name: `predeploy-smoke-${Date.now()}`, isPublic: false }),
|
||||
});
|
||||
assert(created.ok, `Create playlist failed: status=${created.status} body=${JSON.stringify(created.json)}`);
|
||||
const playlistId = created.json?.id;
|
||||
assert(typeof playlistId === "string", `Create playlist missing id: ${JSON.stringify(created.json)}`);
|
||||
|
||||
const add = await fetchJson(`/api/playlists/${playlistId}/items`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ trackId }),
|
||||
});
|
||||
assert(add.ok, `Add track to playlist failed: status=${add.status} body=${JSON.stringify(add.json)}`);
|
||||
|
||||
const del = await fetchJson(`/api/playlists/${playlistId}`, { method: "DELETE", token });
|
||||
assert(del.ok, `Delete playlist failed: status=${del.status} body=${JSON.stringify(del.json)}`);
|
||||
}
|
||||
|
||||
async function playbackStateRoundTrip(token: string) {
|
||||
const trackId = await getOneTrackId(token);
|
||||
assert(
|
||||
trackId,
|
||||
`No tracks found. Set MUSIC_PATH to a library with at least one track, or run a scan before testing.`
|
||||
);
|
||||
|
||||
const payload = {
|
||||
playbackType: "track",
|
||||
trackId,
|
||||
queue: [{ id: trackId }],
|
||||
currentIndex: 0,
|
||||
isShuffle: false,
|
||||
};
|
||||
|
||||
const save = await fetchJson("/api/playback-state", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert(save.ok, `Save playback state failed: status=${save.status} body=${JSON.stringify(save.json)}`);
|
||||
|
||||
const got = await fetchJson("/api/playback-state", { method: "GET", token });
|
||||
assert(got.ok, `Get playback state failed: status=${got.status} body=${JSON.stringify(got.json)}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const started = Date.now();
|
||||
console.log(`[smoke] API_BASE_URL=${API_BASE_URL}`);
|
||||
|
||||
await waitForHealth();
|
||||
console.log("[smoke] health ok");
|
||||
|
||||
const token = await ensureTestUserAndToken();
|
||||
console.log(`[smoke] got token for user=${USERNAME}`);
|
||||
|
||||
await completeOnboarding(token);
|
||||
console.log("[smoke] onboarding marked complete");
|
||||
|
||||
await scanLibraryIfNeeded(token);
|
||||
console.log("[smoke] library ready");
|
||||
|
||||
await playlistsCrud(token);
|
||||
console.log("[smoke] playlists CRUD ok");
|
||||
|
||||
await playbackStateRoundTrip(token);
|
||||
console.log("[smoke] playback-state roundtrip ok");
|
||||
|
||||
console.log(`[smoke] PASS in ${Date.now() - started}ms`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[smoke] FAIL", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user