Files
kima-hub/frontend/tests/e2e/enrichment-cycle.spec.ts
T
Your Name 9083835bfd chore: v1.7.0 -- vibe galaxy, CI pipeline, enrichment hardening, PWA, preprod sweep
- Bump frontend and backend to 1.7.0
- Update CHANGELOG with full 1.7.0 release notes
- Remove vibe-test dev prototype page and unused R3F components
  (VibeUniverse, TrackCloud, TrackTooltip, universeUtils)
- Fix stale audio.completed counter: flush live DB count at isFullyComplete
  transition -- counter was frozen at last audioQueued > 0 cycle value
- Add GitHub Actions CI pipeline: lint/typecheck, unit tests, security scan,
  E2E predeploy, nightly Docker build and push to Hub + GHCR
- Add E2E enrichment cycle spec with 55-min timeout and memory monitoring script
- Add E2E vibe spec covering map, song path, search, alchemy, similar tracks
- PWA hardening: offline fallback, update banner, WCO, manifest fixes
- Production readiness: OOM memory caps in both compose files, DoS/SSRF/auth fixes
- Remove double-auth in systemSettings (requireAdmin already enforces auth)
- Fix mobile vibe page full-height rendering, vibe map timer leak, abort signal wiring
- Fix E2E test helpers: graceful skip with waitFor + try/catch for empty-library CI
- Fix create-e2e-user.sh: admin role, bcrypt shell expansion, psql heredoc quoting
2026-03-16 18:25:08 -05:00

186 lines
7.5 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { loginAsTestUser, getAuthToken } from "./fixtures/test-helpers";
// ---------------------------------------------------------------------------
// Enrichment cycle test
//
// Wipes all enrichment data, triggers a full re-enrich, and verifies the
// system is functional afterward. Intended to catch:
// - Enrichment correctness regressions (wrong counts, missing embeddings)
// - Memory leaks introduced by the enrichment pipeline
//
// Skips gracefully when the library has fewer than 10 tracks (CI containers
// have no music mount).
//
// For memory monitoring run this spec via the companion shell script:
// bash scripts/run-enrichment-memory-test.sh
// ---------------------------------------------------------------------------
const POLL_INTERVAL_MS = 10_000;
const ENRICHMENT_TIMEOUT_MS = 45 * 60 * 1000; // 45 minutes
type EnrichmentStatus = {
status: string;
currentPhase: string | null;
completionNotificationSent: boolean;
tracks: { total: number; completed: number; failed: number };
artists: { total: number; completed: number; failed: number };
audio: { total: number; completed: number; failed: number; processing: number };
};
/** Poll /api/enrichment/status until idle+complete or timeout.
* Returns the final status object. */
async function waitForEnrichment(
page: Parameters<typeof loginAsTestUser>[0],
token: string,
timeoutMs: number,
): Promise<EnrichmentStatus> {
const deadline = Date.now() + timeoutMs;
let last: EnrichmentStatus | null = null;
while (Date.now() < deadline) {
await page.waitForTimeout(POLL_INTERVAL_MS);
try {
const res = await page.request.get("/api/enrichment/status", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) continue;
const s = (await res.json()) as EnrichmentStatus;
last = s;
const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
const pct =
s.tracks.total > 0
? Math.round((s.tracks.completed / s.tracks.total) * 100)
: 0;
const phase = s.currentPhase ? ` phase=${s.currentPhase}` : "";
console.log(
`[${elapsed}s] status=${s.status}${phase} | ` +
`tracks=${s.tracks.completed}/${s.tracks.total} (${pct}%) | ` +
`artists=${s.artists.completed}/${s.artists.total} | ` +
`audio=${s.audio.completed}/${s.audio.total}`,
);
if (s.status === "idle" && s.completionNotificationSent) {
return s;
}
} catch {
// transient error -- keep polling
}
}
throw new Error(
`Enrichment did not complete within ${timeoutMs / 60_000} minutes. ` +
`Last status: ${JSON.stringify(last)}`,
);
}
// ---------------------------------------------------------------------------
test.describe("Enrichment Cycle", () => {
test.beforeEach(async ({ page }) => {
await loginAsTestUser(page);
});
test("wipe and re-enrich: system is functional after full enrichment cycle", async ({ page }) => {
test.setTimeout(55 * 60 * 1000); // 55-minute per-test timeout
const token = await getAuthToken(page);
// Stop any currently running enrichment before wiping
await page.request.post("/api/enrichment/stop", {
headers: { Authorization: `Bearer ${token}` },
});
await page.waitForTimeout(2_000);
// Wipe all enrichment data
const resetRes = await page.request.post("/api/enrichment/reset-all", {
headers: { Authorization: `Bearer ${token}` },
});
expect(resetRes.ok()).toBe(true);
const resetData = (await resetRes.json()) as { tracksReset: number; artistsReset: number };
if (resetData.tracksReset < 10) {
test.skip(
true,
`Library too small (${resetData.tracksReset} tracks) -- skipping enrichment cycle (empty container)`,
);
return;
}
console.log(
`Reset complete: ${resetData.tracksReset} tracks, ${resetData.artistsReset} artists cleared`,
);
// Confirm completionNotificationSent is now falsy (null or false after reset)
const statusAfterReset = await page.request.get("/api/enrichment/status", {
headers: { Authorization: `Bearer ${token}` },
});
const resetStatus = (await statusAfterReset.json()) as EnrichmentStatus;
expect(resetStatus.completionNotificationSent).toBeFalsy();
// Start full enrichment
const startRes = await page.request.post("/api/enrichment/full", {
headers: { Authorization: `Bearer ${token}` },
});
expect(startRes.ok()).toBe(true);
console.log("Full enrichment started");
// Poll until enrichment completes
const finalStatus = await waitForEnrichment(page, token, ENRICHMENT_TIMEOUT_MS);
console.log(
`Enrichment complete: tracks ${finalStatus.tracks.completed}/${finalStatus.tracks.total} | ` +
`audio ${finalStatus.audio.completed}/${finalStatus.audio.total} | ` +
`failures ${finalStatus.tracks.failed + finalStatus.artists.failed}`,
);
// ---- Functional assertions after enrichment -------------------------
// 1. At least 80% of tracks should have completed enrichment
const trackSuccessRate =
finalStatus.tracks.total > 0
? finalStatus.tracks.completed / finalStatus.tracks.total
: 0;
expect(trackSuccessRate).toBeGreaterThanOrEqual(0.8);
// 2. Vibe map should return embedded tracks
const vibeRes = await page.request.get("/api/vibe/map", {
headers: { Authorization: `Bearer ${token}` },
});
expect(vibeRes.ok()).toBe(true);
const vibeData = (await vibeRes.json()) as { tracks: unknown[]; trackCount: number };
expect(Array.isArray(vibeData.tracks)).toBe(true);
expect(vibeData.tracks.length).toBeGreaterThan(0);
// 3. Vibe search should return results for at least one music descriptor.
// "music" is too generic (below the 0.4 similarity threshold), so probe
// several descriptors and require at least one to return results.
const searchCandidates = ["rock", "pop", "electronic", "loud", "bright", "guitar", "fast", "sad", "piano"];
let searchHit = false;
for (const q of searchCandidates) {
const r = await page.request.post("/api/vibe/search", {
data: { query: q, limit: 3 },
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok()) continue;
const d = (await r.json()) as { tracks: unknown[] };
if (d.tracks.length > 0) { searchHit = true; break; }
}
expect(searchHit).toBe(true);
// 4. Failure rate should be below 20%
const failRes = await page.request.get("/api/enrichment/failures/counts", {
headers: { Authorization: `Bearer ${token}` },
});
if (failRes.ok()) {
const fails = (await failRes.json()) as Record<string, number>;
const totalFails = Object.values(fails).reduce((a, b) => a + b, 0);
const failRate = resetData.tracksReset > 0 ? totalFails / resetData.tracksReset : 0;
expect(failRate).toBeLessThan(0.2);
}
});
});