mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
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
This commit is contained in:
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -68,13 +68,16 @@ jobs:
|
||||
KIMA_TEST_PASSWORD: ${{ secrets.KIMA_TEST_PASSWORD }}
|
||||
KIMA_UI_BASE_URL: http://127.0.0.1:3030
|
||||
|
||||
- name: Run queue, playlist, and security tests
|
||||
- name: Run functional tests
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npx playwright test \
|
||||
tests/e2e/smoke.spec.ts \
|
||||
tests/e2e/queue.spec.ts \
|
||||
tests/e2e/playlists.spec.ts \
|
||||
tests/e2e/security.spec.ts \
|
||||
tests/e2e/vibe.spec.ts \
|
||||
tests/e2e/full-ux-audit.spec.ts \
|
||||
--reporter=list
|
||||
env:
|
||||
KIMA_TEST_USERNAME: ${{ secrets.KIMA_TEST_USERNAME }}
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
@@ -78,28 +78,6 @@ jobs:
|
||||
# a live PostgreSQL database. It runs in integration.yml instead.
|
||||
run: npm test -- --passWithNoTests --testPathIgnorePatterns="webhookEventStore"
|
||||
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Audit frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm audit --audit-level=critical || true
|
||||
|
||||
- name: Audit backend
|
||||
working-directory: backend
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm audit --audit-level=critical || true
|
||||
|
||||
build-docker:
|
||||
name: Docker Build Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
|
||||
- name: Audit frontend (critical only)
|
||||
working-directory: frontend
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
|
||||
@@ -5,6 +5,39 @@ All notable changes to Kima will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Vibe 3D galaxy view**: New immersive "galaxy" toggle on the Vibe page renders your library as a navigable 3D star field using React Three Fiber + Three.js. Tracks are positioned by their vibe embeddings, lit by a dynamic space grid with depth lines, and colored by mood. Double-click any point to play. Camera uses map controls with smooth zoom and pan. Rendered in a dedicated `GravityGridScene` with cluster label billboards, optimized point instancing, and post-processing disabled for GPU performance.
|
||||
- **Vibe map pre-compute and KNN acceleration**: Map data is now pre-computed on the backend at enrichment completion rather than on first page load. KNN graph is built incrementally (100-at-a-time) against a pre-sorted index, cutting cold-start time from seconds to milliseconds on large libraries.
|
||||
- **GitHub Actions CI pipeline**: Full multi-stage CI across all PRs and pushes to main. Stages: lint + typecheck (frontend + backend), unit tests (Jest), security scan (CodeQL + secrets), E2E predeploy tests (Playwright against a real Docker build), nightly build + push to Docker Hub and GHCR.
|
||||
- **E2E enrichment cycle spec**: Playwright test that wipes all enrichment data, triggers a full re-enrich, polls until completion, then asserts track success rate >= 80%, vibe map populated, vibe search returning results, and failure rate < 20%. Skips gracefully when the library has fewer than 10 tracks (CI containers). Companion shell script (`scripts/run-enrichment-memory-test.sh`) captures host SUnreclaim and container RSS before/after and fails the run if slab growth exceeds 1 GB.
|
||||
- **E2E vibe spec**: Playwright test covering vibe map load, track selection, song path generation, vibe search, alchemy (track blending), and similar-tracks suggestions.
|
||||
- **PWA offline fallback**: Service worker now serves a cached offline page when navigation requests fail. Cached on install alongside the app shell.
|
||||
- **PWA update banner**: App detects when a new service worker is waiting and shows a non-blocking "Update available" banner. Clicking it activates the new worker and reloads.
|
||||
- **Web app manifest fixes**: `display_override` with `window-controls-overlay` for desktop PWA title bar space. Corrected `scope`, `start_url`, and `id` fields. Added `edge_side_panel` and screenshot metadata.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Enrichment: audio counter stale at completion**: `audio.completed` in the Redis state snapshot was frozen at the value from the last `audioQueued > 0` cycle. Once all tracks were queued, the update block was skipped while Essentia continued processing in the background, leaving the counter stuck (e.g. 171/232). The counter is now flushed from the live DB count at the `isFullyComplete` transition before going idle.
|
||||
- **Security: double authentication in system settings**: `requireAdmin` already enforces authentication, so the redundant `requireAuth` call before it was removed.
|
||||
- **Security: OOM from Next.js VMA accumulation**: The App Router's map-style routes allocate ~15 TB of virtual memory, causing `anon_vma_chain` slab exhaustion on the host kernel. Mitigations: Docker memory cap (`mem_limit: 6g`, `memswap_limit: 8g`) in both compose files; Node 22 in the Dockerfile (V8 improvements reduce anonymous VMAs); `MALLOC_ARENA_MAX=1` for the frontend process (fewer glibc arenas); `NODE_OPTIONS=--max-old-space-size=512` for the frontend (bounds heap to reduce GC-driven VMAs).
|
||||
- **Security: DoS vectors patched**: Unbounded enrichment queue, unguarded bulk endpoints, and routes missing rate limits addressed in the production hardening pass.
|
||||
- **Security: SSRF vector in webhook callback validation**: Callback URLs are now validated against an allowlist of configured hosts before being followed.
|
||||
- **Vibe map accuracy**: SLERP interpolation corrected, similarity formula made consistent between search and map. Distinct mood colors assigned per cluster. ARIA labels added to interactive elements.
|
||||
- **Vibe map timer leak**: `setInterval` polling the map data endpoint was not cleared on unmount. Replaced with React Query cache with explicit `staleTime`.
|
||||
- **Vibe request ref cleanup**: `vibeRequestRef` was not cleaned up on component unmount, causing stale abort signals on re-mount.
|
||||
- **Mobile: full-height vibe page rendering**: CSS height chain corrected from `html`/`body` through layout to vibe page so the map fills the viewport without overflow or clipping on mobile.
|
||||
- **Subsonic: `.view` suffix normalization**: Requests from clients that append `.view` to endpoint names (e.g. `getCoverArt.view`) are now normalized before routing. Expanded OpenSubsonic endpoint compatibility.
|
||||
- **Service worker: stale styles after rebuild**: SW was caching HTML and CSS documents, causing users to see outdated styles until manual cache clear. HTML and CSS are now excluded from the SW cache entirely.
|
||||
- **dead `sourceTrack` variable**: Removed dead variable from vibe request path.
|
||||
- **Abort signal not wired to vibe fetch**: Cancelling a vibe search now properly aborts the in-flight fetch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **vibe-test prototype page removed**: The `/vibe-test` dev sandbox page and its associated R3F prototype components (`VibeUniverse`, `TrackCloud`, `TrackTooltip`, `universeUtils`) have been removed. The production galaxy view lives in `GravityGridScene`.
|
||||
|
||||
## [1.6.4] - 2026-03-12
|
||||
|
||||
Fixes #152. Addresses #145.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kima-backend",
|
||||
"version": "1.6.4",
|
||||
"version": "1.7.0",
|
||||
"description": "Kima backend API server",
|
||||
"license": "GPL-3.0",
|
||||
"repository": {
|
||||
|
||||
@@ -713,9 +713,19 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{
|
||||
}
|
||||
|
||||
if (progress.isFullyComplete) {
|
||||
// Flush final audio counter before going idle -- it may be stale because
|
||||
// the update block above only runs when audioQueued > 0. Once all tracks
|
||||
// are already queued, that block is skipped while Essentia finishes in
|
||||
// the background, leaving the counter frozen at a mid-run snapshot.
|
||||
await enrichmentStateService.updateState({
|
||||
status: "idle",
|
||||
currentPhase: null,
|
||||
audio: {
|
||||
total: progress.audioAnalysis.total,
|
||||
completed: progress.audioAnalysis.completed,
|
||||
failed: progress.audioAnalysis.failed,
|
||||
processing: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-compute vibe map projection so it's cached before first page visit
|
||||
|
||||
@@ -33,6 +33,11 @@ services:
|
||||
# Makes host.docker.internal work on Linux (already works on Docker Desktop)
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Cap memory to prevent host kernel OOM cascade from anon_vma_chain slab exhaustion.
|
||||
# Without this, the host kernel OOM killer fires when Next.js VMA chains accumulate.
|
||||
# 6g is enough for frontend + backend + embeddings + Redis + Postgres.
|
||||
mem_limit: 6g
|
||||
memswap_limit: 8g
|
||||
# Fix Redis memory overcommit warning
|
||||
sysctls:
|
||||
- vm.overcommit_memory=1
|
||||
|
||||
@@ -31,6 +31,11 @@ services:
|
||||
# Makes host.docker.internal work on Linux (already works on Docker Desktop)
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Cap memory to prevent host kernel OOM cascade from anon_vma_chain slab exhaustion.
|
||||
# Without this, the host kernel OOM killer fires when Next.js VMA chains accumulate.
|
||||
# 6g is enough for frontend + backend + embeddings + Redis + Postgres.
|
||||
mem_limit: 6g
|
||||
memswap_limit: 8g
|
||||
# Fix Redis memory overcommit warning
|
||||
sysctls:
|
||||
- vm.overcommit_memory=1
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { MapTrack } from "@/features/vibe/types";
|
||||
|
||||
const VibeUniverse = dynamic(
|
||||
() => import("@/features/vibe/VibeUniverse").then(m => ({ default: m.VibeUniverse })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function VibeTestPage() {
|
||||
const { data: mapData, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["vibe-map"],
|
||||
queryFn: () => api.getVibeMap(),
|
||||
staleTime: 1000 * 60 * 60,
|
||||
gcTime: 1000 * 60 * 60 * 24,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null);
|
||||
const [highlightedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const tracks = mapData?.tracks;
|
||||
const trackMap = useMemo(() => {
|
||||
if (!tracks) return new Map<string, MapTrack>();
|
||||
const map = new Map<string, MapTrack>();
|
||||
for (const track of tracks) {
|
||||
map.set(track.id, track);
|
||||
}
|
||||
return map;
|
||||
}, [tracks]);
|
||||
|
||||
const selectedTrack = selectedTrackId ? trackMap.get(selectedTrackId) ?? null : null;
|
||||
|
||||
const handleTrackClick = useCallback((trackId: string) => {
|
||||
setSelectedTrackId(trackId);
|
||||
}, []);
|
||||
|
||||
const handleBackgroundClick = useCallback(() => {
|
||||
setSelectedTrackId(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-6 h-6 text-[var(--color-ai)] animate-spin mx-auto mb-3 opacity-60" />
|
||||
<p className="text-white/40 text-sm tracking-wide">Loading universe data</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-white/40 text-sm">Failed to load universe data</p>
|
||||
<p className="text-white/20 text-xs mt-1">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 px-4 py-1.5 bg-white/10 hover:bg-white/15 rounded text-xs text-white/60 hover:text-white"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mapData || mapData.tracks.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-white/40 text-sm">No tracks with vibe analysis yet</p>
|
||||
<p className="text-white/20 text-xs mt-1">Run enrichment to generate embeddings</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative overflow-hidden">
|
||||
<VibeUniverse
|
||||
tracks={mapData.tracks}
|
||||
highlightedIds={highlightedIds}
|
||||
selectedTrackId={selectedTrackId}
|
||||
onTrackClick={handleTrackClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
/>
|
||||
|
||||
{selectedTrack && (
|
||||
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 z-10 bg-black/70 backdrop-blur-md border border-white/10 rounded-lg px-4 py-2 text-center">
|
||||
<div className="text-white text-sm font-medium truncate max-w-64">
|
||||
{selectedTrack.title}
|
||||
</div>
|
||||
<div className="text-white/50 text-xs truncate max-w-64">
|
||||
{selectedTrack.artist}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -255,7 +255,6 @@ export function TopBar() {
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{/* TODO: Add notification badge in Phase 3 */}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import * as THREE from "three";
|
||||
import { ThreeEvent } from "@react-three/fiber";
|
||||
import type { MapTrack } from "./types";
|
||||
import { getTrackColor, getTrackHighlightColor, computeEdges } from "./universeUtils";
|
||||
|
||||
function hashToFloat(str: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return ((h & 0x7fffffff) / 0x7fffffff) * 2 - 1;
|
||||
}
|
||||
|
||||
interface TrackCloudProps {
|
||||
tracks: MapTrack[];
|
||||
highlightedIds: Set<string>;
|
||||
selectedTrackId: string | null;
|
||||
onTrackClick: (trackId: string) => void;
|
||||
onTrackHover: (track: MapTrack | null, point: THREE.Vector3 | null) => void;
|
||||
}
|
||||
|
||||
const WORLD_SCALE = 400;
|
||||
|
||||
const vertexShader = `
|
||||
attribute float size;
|
||||
attribute vec3 customColor;
|
||||
attribute float opacity;
|
||||
varying vec3 vColor;
|
||||
varying float vOpacity;
|
||||
void main() {
|
||||
vColor = customColor;
|
||||
vOpacity = opacity;
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = size * (800.0 / -mvPosition.z);
|
||||
gl_PointSize = clamp(gl_PointSize, 3.0, 64.0);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
varying vec3 vColor;
|
||||
varying float vOpacity;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - vec2(0.5));
|
||||
if (d > 0.5) discard;
|
||||
// Solid digital planet -- depth shading with crisp rim
|
||||
float edge = 1.0 - smoothstep(0.44, 0.5, d);
|
||||
float shade = 1.0 - d * 0.4;
|
||||
float rim = smoothstep(0.35, 0.46, d) * edge;
|
||||
vec3 color = vColor * (shade + rim * 0.3);
|
||||
float alpha = edge * vOpacity;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export function TrackCloud({
|
||||
tracks,
|
||||
highlightedIds,
|
||||
selectedTrackId,
|
||||
onTrackClick,
|
||||
onTrackHover,
|
||||
}: TrackCloudProps) {
|
||||
const pointsRef = useRef<THREE.Points>(null!);
|
||||
const linesRef = useRef<THREE.LineSegments>(null!);
|
||||
|
||||
const hasHighlights = highlightedIds.size > 0;
|
||||
|
||||
// Compute positions once (shared between points and lines)
|
||||
const positions = useMemo(() => {
|
||||
const pos = new Float32Array(tracks.length * 3);
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
pos[i * 3] = tracks[i].x * WORLD_SCALE;
|
||||
pos[i * 3 + 1] = tracks[i].y * WORLD_SCALE;
|
||||
pos[i * 3 + 2] = hashToFloat(tracks[i].id) * WORLD_SCALE * 0.2;
|
||||
}
|
||||
return pos;
|
||||
}, [tracks]);
|
||||
|
||||
// Points geometry and material
|
||||
const { pointGeo, pointMat } = useMemo(() => {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const colors = new Float32Array(tracks.length * 3);
|
||||
const sizes = new Float32Array(tracks.length);
|
||||
const opacities = new Float32Array(tracks.length);
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
const color = getTrackColor(track);
|
||||
colors[i * 3] = color.r;
|
||||
colors[i * 3 + 1] = color.g;
|
||||
colors[i * 3 + 2] = color.b;
|
||||
|
||||
const energy = track.energy ?? 0.5;
|
||||
sizes[i] = 8.0 + energy * 12.0;
|
||||
opacities[i] = 0.85;
|
||||
}
|
||||
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute("customColor", new THREE.BufferAttribute(colors, 3));
|
||||
geo.setAttribute("size", new THREE.BufferAttribute(sizes, 1));
|
||||
geo.setAttribute("opacity", new THREE.BufferAttribute(opacities, 1));
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.NormalBlending,
|
||||
});
|
||||
|
||||
return { pointGeo: geo, pointMat: mat };
|
||||
}, [tracks, positions]);
|
||||
|
||||
// Connection lines geometry
|
||||
const { lineGeo, lineMat } = useMemo(() => {
|
||||
const edges = computeEdges(tracks, 3);
|
||||
const linePositions = new Float32Array(edges.length * 6);
|
||||
const lineColors = new Float32Array(edges.length * 6);
|
||||
|
||||
for (let e = 0; e < edges.length; e++) {
|
||||
const [i, j] = edges[e];
|
||||
linePositions[e * 6] = positions[i * 3];
|
||||
linePositions[e * 6 + 1] = positions[i * 3 + 1];
|
||||
linePositions[e * 6 + 2] = positions[i * 3 + 2];
|
||||
linePositions[e * 6 + 3] = positions[j * 3];
|
||||
linePositions[e * 6 + 4] = positions[j * 3 + 1];
|
||||
linePositions[e * 6 + 5] = positions[j * 3 + 2];
|
||||
|
||||
// Blend colors of both endpoints, very dim
|
||||
const ci = getTrackColor(tracks[i]);
|
||||
const cj = getTrackColor(tracks[j]);
|
||||
const lr = (ci.r + cj.r) * 0.5;
|
||||
const lg = (ci.g + cj.g) * 0.5;
|
||||
const lb = (ci.b + cj.b) * 0.5;
|
||||
lineColors[e * 6] = lr;
|
||||
lineColors[e * 6 + 1] = lg;
|
||||
lineColors[e * 6 + 2] = lb;
|
||||
lineColors[e * 6 + 3] = lr;
|
||||
lineColors[e * 6 + 4] = lg;
|
||||
lineColors[e * 6 + 5] = lb;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(linePositions, 3));
|
||||
geo.setAttribute("color", new THREE.BufferAttribute(lineColors, 3));
|
||||
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
blending: THREE.NormalBlending,
|
||||
});
|
||||
|
||||
return { lineGeo: geo, lineMat: mat };
|
||||
}, [tracks, positions]);
|
||||
|
||||
// Update colors/opacity when highlights or selection change
|
||||
useEffect(() => {
|
||||
if (!pointGeo || tracks.length === 0) return;
|
||||
|
||||
const colors = pointGeo.getAttribute("customColor") as THREE.BufferAttribute;
|
||||
const opacities = pointGeo.getAttribute("opacity") as THREE.BufferAttribute;
|
||||
const sizes = pointGeo.getAttribute("size") as THREE.BufferAttribute;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
const isHighlighted = !hasHighlights || highlightedIds.has(track.id);
|
||||
const isSelected = track.id === selectedTrackId;
|
||||
const energy = track.energy ?? 0.5;
|
||||
|
||||
if (isSelected) {
|
||||
colors.setXYZ(i, 0.9, 0.9, 0.9);
|
||||
opacities.setX(i, 1.0);
|
||||
sizes.setX(i, (8.0 + energy * 12.0) * 1.8);
|
||||
} else if (isHighlighted) {
|
||||
const c = getTrackHighlightColor(track);
|
||||
colors.setXYZ(i, c.r, c.g, c.b);
|
||||
opacities.setX(i, 0.9);
|
||||
sizes.setX(i, 8.0 + energy * 12.0);
|
||||
} else {
|
||||
const c = getTrackColor(track);
|
||||
colors.setXYZ(i, c.r * 0.4, c.g * 0.4, c.b * 0.4);
|
||||
opacities.setX(i, 0.25);
|
||||
sizes.setX(i, (8.0 + energy * 12.0) * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
colors.needsUpdate = true;
|
||||
opacities.needsUpdate = true;
|
||||
sizes.needsUpdate = true;
|
||||
}, [tracks, highlightedIds, selectedTrackId, hasHighlights, pointGeo]);
|
||||
|
||||
const handleClick = useCallback((e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
if (e.index !== undefined && e.index < tracks.length) {
|
||||
onTrackClick(tracks[e.index].id);
|
||||
}
|
||||
}, [tracks, onTrackClick]);
|
||||
|
||||
const handlePointerOver = useCallback((e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation();
|
||||
if (e.index !== undefined && e.index < tracks.length) {
|
||||
const track = tracks[e.index];
|
||||
const point = new THREE.Vector3(
|
||||
track.x * WORLD_SCALE,
|
||||
track.y * WORLD_SCALE,
|
||||
hashToFloat(track.id) * WORLD_SCALE * 0.2
|
||||
);
|
||||
onTrackHover(track, point);
|
||||
}
|
||||
}, [tracks, onTrackHover]);
|
||||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
onTrackHover(null, null);
|
||||
}, [onTrackHover]);
|
||||
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Connection lines (rendered behind points) */}
|
||||
<lineSegments
|
||||
ref={linesRef}
|
||||
geometry={lineGeo}
|
||||
material={lineMat}
|
||||
/>
|
||||
{/* Track points */}
|
||||
<points
|
||||
ref={pointsRef}
|
||||
geometry={pointGeo}
|
||||
material={pointMat}
|
||||
onClick={handleClick}
|
||||
onPointerOver={handlePointerOver}
|
||||
onPointerOut={handlePointerOut}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export { WORLD_SCALE };
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Html } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { MapTrack } from "./types";
|
||||
|
||||
interface TrackTooltipProps {
|
||||
track: MapTrack;
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
|
||||
export function TrackTooltip({ track, position }: TrackTooltipProps) {
|
||||
return (
|
||||
<Html
|
||||
position={position}
|
||||
center
|
||||
style={{ pointerEvents: "none" }}
|
||||
zIndexRange={[50, 0]}
|
||||
>
|
||||
<div className="bg-black/80 backdrop-blur-sm border border-white/10 rounded-lg px-3 py-1.5 text-center whitespace-nowrap">
|
||||
<div className="text-white text-xs font-medium truncate max-w-48">
|
||||
{track.title}
|
||||
</div>
|
||||
<div className="text-white/50 text-[10px] truncate max-w-48">
|
||||
{track.artist}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect, Suspense } from "react";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
OrthographicCamera,
|
||||
PerspectiveCamera,
|
||||
OrbitControls,
|
||||
PointerLockControls,
|
||||
} from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { MapTrack } from "./types";
|
||||
import { TrackCloud, WORLD_SCALE } from "./TrackCloud";
|
||||
import { TrackTooltip } from "./TrackTooltip";
|
||||
|
||||
interface VibeUniverseProps {
|
||||
tracks: MapTrack[];
|
||||
highlightedIds: Set<string>;
|
||||
selectedTrackId: string | null;
|
||||
onTrackClick: (trackId: string) => void;
|
||||
onBackgroundClick: () => void;
|
||||
}
|
||||
|
||||
function useIsMobile(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.innerWidth < 768;
|
||||
}
|
||||
|
||||
function FlyMovement({ speed = 30 }: { speed?: number }) {
|
||||
const { camera } = useThree();
|
||||
const keys = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
keys.current.add(e.code);
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
keys.current.delete(e.code);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const velocity = new THREE.Vector3();
|
||||
const boost = keys.current.has("KeyR") ? 3 : 1;
|
||||
const actualSpeed = speed * boost;
|
||||
|
||||
if (keys.current.has("KeyW") || keys.current.has("ArrowUp")) velocity.z -= 1;
|
||||
if (keys.current.has("KeyS") || keys.current.has("ArrowDown")) velocity.z += 1;
|
||||
if (keys.current.has("KeyA") || keys.current.has("ArrowLeft")) velocity.x -= 1;
|
||||
if (keys.current.has("KeyD") || keys.current.has("ArrowRight")) velocity.x += 1;
|
||||
if (keys.current.has("Space")) velocity.y += 1;
|
||||
if (keys.current.has("ShiftLeft") || keys.current.has("ShiftRight")) velocity.y -= 1;
|
||||
|
||||
if (velocity.length() > 0) {
|
||||
velocity.normalize().multiplyScalar(actualSpeed * delta);
|
||||
velocity.applyQuaternion(camera.quaternion);
|
||||
camera.position.add(velocity);
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TronGrid({ worldCenter }: { worldCenter: readonly [number, number, number] }) {
|
||||
const material = useMemo(() => {
|
||||
const halfSize = WORLD_SCALE * 2.0;
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uCenter: { value: new THREE.Vector3(worldCenter[0], worldCenter[1], 0) },
|
||||
uGridSpacing: { value: WORLD_SCALE * 0.06 },
|
||||
uHalfSize: { value: halfSize },
|
||||
uColorA: { value: new THREE.Color(168 / 255, 85 / 255, 247 / 255) },
|
||||
uColorB: { value: new THREE.Color(252 / 255, 162 / 255, 0) },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec3 vWorldPos;
|
||||
void main() {
|
||||
vec4 worldPos = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPos = worldPos.xyz;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uCenter;
|
||||
uniform float uGridSpacing;
|
||||
uniform float uHalfSize;
|
||||
uniform vec3 uColorA;
|
||||
uniform vec3 uColorB;
|
||||
varying vec3 vWorldPos;
|
||||
void main() {
|
||||
vec2 cXY = vWorldPos.xy / uGridSpacing;
|
||||
vec2 gXY = abs(fract(cXY - 0.5) - 0.5) / fwidth(cXY);
|
||||
float lXY = min(gXY.x, gXY.y);
|
||||
|
||||
vec2 cXZ = vWorldPos.xz / uGridSpacing;
|
||||
vec2 gXZ = abs(fract(cXZ - 0.5) - 0.5) / fwidth(cXZ);
|
||||
float lXZ = min(gXZ.x, gXZ.y);
|
||||
|
||||
vec2 cYZ = vWorldPos.yz / uGridSpacing;
|
||||
vec2 gYZ = abs(fract(cYZ - 0.5) - 0.5) / fwidth(cYZ);
|
||||
float lYZ = min(gYZ.x, gYZ.y);
|
||||
|
||||
float line = min(lXY, min(lXZ, lYZ));
|
||||
float alpha = 1.0 - min(line, 1.0);
|
||||
|
||||
float t = smoothstep(-uHalfSize, uHalfSize, vWorldPos.y - uCenter.y);
|
||||
vec3 color = mix(uColorA, uColorB, t);
|
||||
|
||||
float dist = length(vWorldPos - uCenter) / (uHalfSize * 1.2);
|
||||
alpha *= smoothstep(1.0, 0.3, dist);
|
||||
alpha *= 0.08;
|
||||
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: THREE.BackSide,
|
||||
});
|
||||
}, [worldCenter]);
|
||||
|
||||
const boxSize = WORLD_SCALE * 4;
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={[worldCenter[0], worldCenter[1], 0]}
|
||||
material={material}
|
||||
>
|
||||
<boxGeometry args={[boxSize, boxSize, boxSize]} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneContent({
|
||||
tracks,
|
||||
highlightedIds,
|
||||
selectedTrackId,
|
||||
is3D,
|
||||
isMobile: _isMobile,
|
||||
isLocked,
|
||||
onLockChange,
|
||||
onTrackClick,
|
||||
onBackgroundClick: _onBackgroundClick,
|
||||
}: VibeUniverseProps & {
|
||||
is3D: boolean;
|
||||
isMobile: boolean;
|
||||
isLocked: boolean;
|
||||
onLockChange: (locked: boolean) => void;
|
||||
}) {
|
||||
const [hoveredTrack, setHoveredTrack] = useState<MapTrack | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<THREE.Vector3 | null>(null);
|
||||
|
||||
const handleTrackHover = useCallback((track: MapTrack | null, point: THREE.Vector3 | null) => {
|
||||
setHoveredTrack(track);
|
||||
setHoverPosition(point);
|
||||
}, []);
|
||||
|
||||
const { center, span } = useMemo(() => {
|
||||
if (tracks.length === 0) {
|
||||
return { center: [0.5, 0.5] as const, span: 1 };
|
||||
}
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const t of tracks) {
|
||||
if (t.x < minX) minX = t.x;
|
||||
if (t.x > maxX) maxX = t.x;
|
||||
if (t.y < minY) minY = t.y;
|
||||
if (t.y > maxY) maxY = t.y;
|
||||
}
|
||||
return {
|
||||
center: [(minX + maxX) / 2, (minY + maxY) / 2] as const,
|
||||
span: Math.max(maxX - minX, maxY - minY) || 1,
|
||||
};
|
||||
}, [tracks]);
|
||||
|
||||
const worldCenter = useMemo(
|
||||
() => [center[0] * WORLD_SCALE, center[1] * WORLD_SCALE, 0] as const,
|
||||
[center]
|
||||
);
|
||||
|
||||
const handleLock = useCallback(() => onLockChange(true), [onLockChange]);
|
||||
const handleUnlock = useCallback(() => onLockChange(false), [onLockChange]);
|
||||
|
||||
// 2D zoom: fit all tracks in view with padding
|
||||
const orthoZoom = useMemo(() => {
|
||||
if (typeof window === "undefined") return 2;
|
||||
const viewportMin = Math.min(window.innerWidth, window.innerHeight);
|
||||
const worldSpan = span * WORLD_SCALE;
|
||||
return viewportMin / (worldSpan * 1.3);
|
||||
}, [span]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{is3D ? (
|
||||
<>
|
||||
<PerspectiveCamera
|
||||
makeDefault
|
||||
position={[worldCenter[0], worldCenter[1], WORLD_SCALE * span * 0.6]}
|
||||
fov={60}
|
||||
near={0.1}
|
||||
far={WORLD_SCALE * 5}
|
||||
/>
|
||||
<PointerLockControls onLock={handleLock} onUnlock={handleUnlock} />
|
||||
<FlyMovement speed={WORLD_SCALE * 0.08} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<OrthographicCamera
|
||||
makeDefault
|
||||
position={[worldCenter[0], worldCenter[1], 100]}
|
||||
zoom={orthoZoom}
|
||||
near={0.1}
|
||||
far={WORLD_SCALE * 5}
|
||||
/>
|
||||
<OrbitControls
|
||||
enableRotate={false}
|
||||
enableDamping
|
||||
dampingFactor={0.12}
|
||||
target={[worldCenter[0], worldCenter[1], 0]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tron-style background grid */}
|
||||
<TronGrid worldCenter={worldCenter} />
|
||||
|
||||
<TrackCloud
|
||||
tracks={tracks}
|
||||
highlightedIds={highlightedIds}
|
||||
selectedTrackId={selectedTrackId}
|
||||
onTrackClick={onTrackClick}
|
||||
onTrackHover={handleTrackHover}
|
||||
/>
|
||||
|
||||
{hoveredTrack && hoverPosition && !isLocked && (
|
||||
<TrackTooltip track={hoveredTrack} position={hoverPosition} />
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VibeUniverse({
|
||||
tracks,
|
||||
highlightedIds,
|
||||
selectedTrackId,
|
||||
onTrackClick,
|
||||
onBackgroundClick,
|
||||
}: VibeUniverseProps) {
|
||||
const [is3D, setIs3D] = useState(false);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<Canvas
|
||||
dpr={[1, 1.5]}
|
||||
gl={{
|
||||
antialias: true,
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{ background: "#050508" }}
|
||||
onPointerMissed={onBackgroundClick}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SceneContent
|
||||
tracks={tracks}
|
||||
highlightedIds={highlightedIds}
|
||||
selectedTrackId={selectedTrackId}
|
||||
is3D={is3D}
|
||||
isMobile={isMobile}
|
||||
isLocked={isLocked}
|
||||
onLockChange={setIsLocked}
|
||||
onTrackClick={onTrackClick}
|
||||
onBackgroundClick={onBackgroundClick}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
||||
{/* 2D / 3D toggle */}
|
||||
<div className="absolute top-4 right-4 z-10 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIs3D(!is3D)}
|
||||
className="px-3 py-1.5 rounded-lg backdrop-blur-md border text-xs font-medium transition-colors bg-white/10 border-white/10 text-white/70 hover:text-white hover:bg-white/15"
|
||||
>
|
||||
{is3D ? "2D" : "3D"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 3D mode instructions */}
|
||||
{is3D && !isLocked && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<p className="text-white/60 text-sm mb-1">Click anywhere to explore</p>
|
||||
<p className="text-white/30 text-xs">WASD to move -- Mouse to look -- R for boost -- ESC to exit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Track count */}
|
||||
<div className="absolute bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-[max(0.75rem,env(safe-area-inset-left))] z-10 text-white/15 text-[10px] tracking-widest uppercase font-medium">
|
||||
{tracks.length} tracks
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import type { MapTrack } from "./types";
|
||||
import { blendMoodColorRGB } from "./mapUtils";
|
||||
|
||||
// Three.js renders in linear light -- use a higher saturation boost (2.0) than
|
||||
// the Deck.gl sRGB path (1.6) to compensate for the linearization difference.
|
||||
function blendMoodColor(track: MapTrack): [number, number, number] {
|
||||
return blendMoodColorRGB(track, 2.0);
|
||||
}
|
||||
|
||||
/** Returns a Three.js Color for a track, normalized to 0-1 range. */
|
||||
export function getTrackThreeColor(track: MapTrack): THREE.Color {
|
||||
const [r, g, b] = blendMoodColor(track);
|
||||
return new THREE.Color(r / 255, g / 255, b / 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a subdued color for the Tron aesthetic. Base brightness 0.15-0.35,
|
||||
* with energy adding a subtle lift. No HDR -- bloom is handled separately.
|
||||
*/
|
||||
export function getTrackColor(track: MapTrack): THREE.Color {
|
||||
const [r, g, b] = blendMoodColor(track);
|
||||
const energy = track.energy ?? 0.5;
|
||||
const brightness = 0.25 + energy * 0.3;
|
||||
return new THREE.Color(
|
||||
(r / 255) * brightness,
|
||||
(g / 255) * brightness,
|
||||
(b / 255) * brightness
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a brighter variant for selected/highlighted tracks. */
|
||||
export function getTrackHighlightColor(track: MapTrack): THREE.Color {
|
||||
const [r, g, b] = blendMoodColor(track);
|
||||
return new THREE.Color(r / 255 * 0.7, g / 255 * 0.7, b / 255 * 0.7);
|
||||
}
|
||||
|
||||
/** Compute edges connecting each track to its K spatially nearest neighbors. */
|
||||
export function computeEdges(
|
||||
tracks: MapTrack[],
|
||||
k = 3
|
||||
): Array<[number, number]> {
|
||||
const edges = new Set<string>();
|
||||
const result: Array<[number, number]> = [];
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const dists: Array<{ j: number; d: number }> = [];
|
||||
for (let j = 0; j < tracks.length; j++) {
|
||||
if (i === j) continue;
|
||||
const dx = tracks[i].x - tracks[j].x;
|
||||
const dy = tracks[i].y - tracks[j].y;
|
||||
dists.push({ j, d: dx * dx + dy * dy });
|
||||
}
|
||||
dists.sort((a, b) => a.d - b.d);
|
||||
for (let n = 0; n < Math.min(k, dists.length); n++) {
|
||||
const j = dists[n].j;
|
||||
const key = i < j ? `${i}-${j}` : `${j}-${i}`;
|
||||
if (!edges.has(key)) {
|
||||
edges.add(key);
|
||||
result.push([i, j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes bounding sphere for a set of tracks (for zoom-to-cluster).
|
||||
* Coordinates are in raw 0-1 space -- caller must scale if needed.
|
||||
*/
|
||||
export function computeClusterBounds(
|
||||
tracks: MapTrack[],
|
||||
trackIds: Set<string>
|
||||
): { center: THREE.Vector3; radius: number } {
|
||||
const points: THREE.Vector3[] = [];
|
||||
for (const t of tracks) {
|
||||
if (trackIds.has(t.id)) {
|
||||
points.push(new THREE.Vector3(t.x, t.y, 0));
|
||||
}
|
||||
}
|
||||
if (points.length === 0) {
|
||||
return { center: new THREE.Vector3(0.5, 0.5, 0), radius: 0.5 };
|
||||
}
|
||||
|
||||
const center = new THREE.Vector3();
|
||||
for (const p of points) center.add(p);
|
||||
center.divideScalar(points.length);
|
||||
|
||||
let maxDist = 0;
|
||||
for (const p of points) {
|
||||
const d = center.distanceTo(p);
|
||||
if (d > maxDist) maxDist = d;
|
||||
}
|
||||
|
||||
return { center, radius: Math.max(maxDist * 1.3, 0.05) };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kima-frontend",
|
||||
"version": "1.6.4",
|
||||
"version": "1.7.0",
|
||||
"description": "Kima web frontend",
|
||||
"license": "GPL-3.0",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page, TestInfo } from "@playwright/test";
|
||||
import { Page, TestInfo, test } from "@playwright/test";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
@@ -43,11 +43,17 @@ export async function waitForApiHealth(page: Page, timeoutMs = 30000): Promise<v
|
||||
throw new Error("API health check timed out");
|
||||
}
|
||||
|
||||
/** Navigate to the first available album and start playing all tracks. */
|
||||
/** Navigate to the first available album and start playing all tracks.
|
||||
* Skips gracefully if the library has no music (e.g., bare CI container). */
|
||||
export async function startPlayingFirstAlbum(page: Page): Promise<void> {
|
||||
await page.goto("/collection?tab=albums");
|
||||
const firstAlbum = page.locator('a[href^="/album/"]').first();
|
||||
await firstAlbum.waitFor({ timeout: 10_000 });
|
||||
try {
|
||||
await firstAlbum.waitFor({ timeout: 10_000 });
|
||||
} catch {
|
||||
test.skip(true, "No music in library -- skipping (empty container)");
|
||||
return;
|
||||
}
|
||||
await firstAlbum.click();
|
||||
await page.waitForURL(/\/album\//);
|
||||
await page.getByLabel("Play all").click();
|
||||
|
||||
@@ -16,27 +16,39 @@ test.describe("Library", () => {
|
||||
await page.goto("/collection?tab=albums");
|
||||
await expect(page.getByRole("heading", { name: /collection/i })).toBeVisible();
|
||||
|
||||
// Should have at least one album link
|
||||
const albumLinks = page.locator('a[href^="/album/"]');
|
||||
await expect(albumLinks.first()).toBeVisible({ timeout: 10000 });
|
||||
try {
|
||||
await albumLinks.first().waitFor({ timeout: 8_000 });
|
||||
} catch {
|
||||
test.skip(true, "No albums in library -- skipping"); return;
|
||||
}
|
||||
await expect(albumLinks.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("artists tab shows artist list", async ({ page }) => {
|
||||
await page.goto("/collection?tab=artists");
|
||||
await expect(page.getByRole("heading", { name: /collection/i })).toBeVisible();
|
||||
|
||||
// Should have at least one artist link
|
||||
const artistLinks = page.locator('a[href^="/artist/"]');
|
||||
await expect(artistLinks.first()).toBeVisible({ timeout: 10000 });
|
||||
try {
|
||||
await artistLinks.first().waitFor({ timeout: 8_000 });
|
||||
} catch {
|
||||
test.skip(true, "No artists in library -- skipping"); return;
|
||||
}
|
||||
await expect(artistLinks.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("tracks tab shows track list", async ({ page }) => {
|
||||
await page.goto("/collection?tab=tracks");
|
||||
await expect(page.getByRole("heading", { name: /collection/i })).toBeVisible();
|
||||
|
||||
// Should have at least one track in the list
|
||||
const trackRows = page.locator('[data-track-id], [class*="track"]');
|
||||
await expect(trackRows.first()).toBeVisible({ timeout: 10000 });
|
||||
try {
|
||||
await trackRows.first().waitFor({ timeout: 8_000 });
|
||||
} catch {
|
||||
test.skip(true, "No tracks in library -- skipping"); return;
|
||||
}
|
||||
await expect(trackRows.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("search page accessible", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsTestUser, getAuthToken } from "./fixtures/test-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the first two track IDs that have vibe embeddings, or null if none. */
|
||||
async function getVibeTrackIds(page: Parameters<typeof loginAsTestUser>[0]): Promise<[string, string] | null> {
|
||||
try {
|
||||
const token = await getAuthToken(page);
|
||||
const res = await page.request.get("/api/vibe/map", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) return null;
|
||||
const data = await res.json() as { tracks?: Array<{ id: string; title: string; artist: string }> };
|
||||
const tracks = data.tracks ?? [];
|
||||
if (tracks.length < 2) return null;
|
||||
return [tracks[0].id, tracks[tracks.length - 1].id];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds two distinct vibe search queries (music descriptors) that each return
|
||||
* at least one result. Returns null if the library lacks sufficient embeddings.
|
||||
*/
|
||||
async function getTwoVibeSearchQueries(page: Parameters<typeof loginAsTestUser>[0]): Promise<[string, string] | null> {
|
||||
const candidates = ["rock", "pop", "electronic", "bright", "run", "soft", "dark", "sad", "piano"];
|
||||
const token = await getAuthToken(page);
|
||||
const working: string[] = [];
|
||||
for (const q of candidates) {
|
||||
if (working.length >= 2) break;
|
||||
try {
|
||||
const r = await page.request.post("/api/vibe/search", {
|
||||
data: { query: q, limit: 5 },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!r.ok()) continue;
|
||||
const d = await r.json() as { tracks: unknown[] };
|
||||
if (d.tracks.length > 0) working.push(q);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return working.length >= 2 ? [working[0], working[1]] : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Vibe", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsTestUser(page);
|
||||
});
|
||||
|
||||
// ---- Page load ----------------------------------------------------------
|
||||
|
||||
test("vibe page renders canvas or no-data state", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Either a canvas (map rendered) or the no-data placeholder must appear
|
||||
const canvas = page.locator("canvas");
|
||||
const noData = page.locator("text=/No tracks with vibe|Computing music map/i");
|
||||
|
||||
await Promise.race([
|
||||
canvas.waitFor({ timeout: 35_000 }),
|
||||
noData.waitFor({ timeout: 35_000 }),
|
||||
]);
|
||||
|
||||
const hasCanvas = (await canvas.count()) > 0;
|
||||
const hasNoData = (await noData.count()) > 0;
|
||||
expect(hasCanvas || hasNoData).toBe(true);
|
||||
});
|
||||
|
||||
test("toolbar buttons are present when map loads", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const noData = page.locator("text=/No tracks with vibe/i").first();
|
||||
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
|
||||
|
||||
if ((await noData.count()) > 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator('[title="Drift -- journey between two tracks"]')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator('[title="Blend -- mix tracks to find new vibes"]')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator('[aria-label="Search tracks or artists"]')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ---- API contract -------------------------------------------------------
|
||||
|
||||
test("GET /api/vibe/map returns valid structure", async ({ page }) => {
|
||||
const token = await getAuthToken(page);
|
||||
const res = await page.request.get("/api/vibe/map", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// 200 with tracks array (even if empty) or 204
|
||||
if (res.status() === 204) return; // no data yet -- valid
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const data = await res.json() as { tracks: unknown[]; trackCount: number };
|
||||
expect(Array.isArray(data.tracks)).toBe(true);
|
||||
expect(typeof data.trackCount).toBe("number");
|
||||
});
|
||||
|
||||
test("GET /api/vibe/similar returns tracks array for a valid id", async ({ page }) => {
|
||||
const ids = await getVibeTrackIds(page);
|
||||
if (!ids) { test.skip(); return; }
|
||||
|
||||
const token = await getAuthToken(page);
|
||||
const res = await page.request.get(`/api/vibe/similar/${ids[0]}?limit=10`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const data = await res.json() as { tracks: Array<{ id: string; title: string }> };
|
||||
expect(Array.isArray(data.tracks)).toBe(true);
|
||||
expect(data.tracks.length).toBeGreaterThan(0);
|
||||
// Returned tracks should all have ids and titles
|
||||
for (const t of data.tracks.slice(0, 5)) {
|
||||
expect(t.id).toBeTruthy();
|
||||
expect(t.title).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/vibe/path returns a path with start and end tracks", async ({ page }) => {
|
||||
const ids = await getVibeTrackIds(page);
|
||||
if (!ids) { test.skip(); return; }
|
||||
|
||||
const token = await getAuthToken(page);
|
||||
const res = await page.request.post("/api/vibe/path", {
|
||||
data: { startTrackId: ids[0], endTrackId: ids[1], length: 8, mode: "smooth" },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const data = await res.json() as {
|
||||
startTrack: { id: string };
|
||||
endTrack: { id: string };
|
||||
path: Array<{ id: string }>;
|
||||
};
|
||||
expect(data.startTrack.id).toBe(ids[0]);
|
||||
expect(data.endTrack.id).toBe(ids[1]);
|
||||
expect(Array.isArray(data.path)).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Vibe search --------------------------------------------------------
|
||||
|
||||
test("vibe search highlights matching tracks", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const noData = page.locator("text=/No tracks with vibe/i").first();
|
||||
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
|
||||
if ((await noData.count()) > 0) { test.skip(); return; }
|
||||
|
||||
// Type a query that is likely to match something
|
||||
const searchInput = page.locator('[aria-label="Search tracks or artists"]');
|
||||
await searchInput.fill("the");
|
||||
await page.waitForTimeout(400); // debounce
|
||||
|
||||
// Clear search
|
||||
const clearBtn = page.locator('[aria-label="Clear search"]');
|
||||
if (await clearBtn.isVisible()) await clearBtn.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// After clearing, no error -- map is still rendered
|
||||
await expect(canvas).toBeVisible();
|
||||
});
|
||||
|
||||
// ---- Drift via Song Path form -------------------------------------------
|
||||
|
||||
test("Drift button opens song path form", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const noData = page.locator("text=/No tracks with vibe/i").first();
|
||||
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
|
||||
if ((await noData.count()) > 0) { test.skip(); return; }
|
||||
|
||||
await page.locator('[title="Drift -- journey between two tracks"]').click();
|
||||
|
||||
await expect(page.locator('#path-start')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator('#path-end')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator('button:has-text("Generate Path")')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Drift song path form: search and select two tracks then generate queue", async ({ page }) => {
|
||||
// Find queries that produce results in this library
|
||||
const queries = await getTwoVibeSearchQueries(page);
|
||||
if (!queries) { test.skip(); return; }
|
||||
const [startQuery, endQuery] = queries;
|
||||
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
await canvas.waitFor({ timeout: 35_000 });
|
||||
|
||||
// Open drift form
|
||||
await page.locator('[title="Drift -- journey between two tracks"]').click();
|
||||
await expect(page.locator('#path-start')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Search and select start track
|
||||
const startInput = page.locator('#path-start');
|
||||
await startInput.click();
|
||||
await startInput.fill(startQuery);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const firstResult = page.locator('.max-h-40 button').first();
|
||||
await firstResult.waitFor({ timeout: 8_000 });
|
||||
await firstResult.click();
|
||||
|
||||
// Should auto-focus end input
|
||||
const endInput = page.locator('#path-end');
|
||||
await endInput.click();
|
||||
await endInput.fill(endQuery);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const endResult = page.locator('.max-h-40 button').first();
|
||||
await endResult.waitFor({ timeout: 8_000 });
|
||||
await endResult.click();
|
||||
|
||||
// Generate Path button should now be enabled
|
||||
const generateBtn = page.locator('button:has-text("Generate Path")');
|
||||
await expect(generateBtn).toBeEnabled({ timeout: 3_000 });
|
||||
await generateBtn.click();
|
||||
|
||||
// The form closes and the path is visualized on the map (canvas still present)
|
||||
await expect(page.locator('#path-start')).not.toBeVisible({ timeout: 8_000 });
|
||||
await expect(canvas).toBeVisible();
|
||||
});
|
||||
|
||||
// ---- Blend panel --------------------------------------------------------
|
||||
|
||||
test("Blend button opens blend panel", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const noData = page.locator("text=/No tracks with vibe/i").first();
|
||||
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
|
||||
if ((await noData.count()) > 0) { test.skip(); return; }
|
||||
|
||||
await page.locator('[title="Blend -- mix tracks to find new vibes"]').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Blend (alchemy) panel should appear -- close button has aria-label="Close alchemy"
|
||||
const closeEl = page.locator('[aria-label="Close alchemy"]').first();
|
||||
await expect(closeEl).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Dismiss
|
||||
if (await closeEl.isVisible()) await closeEl.click();
|
||||
});
|
||||
|
||||
// ---- Map / Galaxy view toggle -------------------------------------------
|
||||
|
||||
test("Map and Galaxy view buttons are present and switch view", async ({ page }) => {
|
||||
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const noData = page.locator("text=/No tracks with vibe/i").first();
|
||||
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
|
||||
if ((await noData.count()) > 0) { test.skip(); return; }
|
||||
|
||||
// Map button (already active)
|
||||
const mapBtn = page.locator("button").filter({ hasText: /^Map$/ }).first();
|
||||
const galaxyBtn = page.locator("button").filter({ hasText: /^Galaxy$/ }).first();
|
||||
await expect(mapBtn).toBeVisible({ timeout: 5_000 });
|
||||
await expect(galaxyBtn).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Switch to Galaxy
|
||||
await galaxyBtn.click();
|
||||
await page.waitForTimeout(2_000);
|
||||
// Canvas should still be present (WebGL scene renders)
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
// Switch back to Map
|
||||
await mapBtn.click();
|
||||
await page.waitForTimeout(800);
|
||||
await expect(canvas).toBeVisible();
|
||||
});
|
||||
});
|
||||
+17
-11
@@ -27,17 +27,23 @@ HASH=$(docker exec -e "TEST_PASS=${TEST_PASS}" "${CONTAINER}" bash -c '
|
||||
"
|
||||
')
|
||||
|
||||
# Upsert the user with the generated hash
|
||||
docker exec "${CONTAINER}" bash -c "
|
||||
psql -U kima -d kima -c \"
|
||||
INSERT INTO \\\"User\\\" (id, username, \\\"passwordHash\\\", role, \\\"onboardingComplete\\\")
|
||||
VALUES ('e2e_test_user_kima', '${TEST_USER}', '${HASH}', 'user', true)
|
||||
ON CONFLICT (username) DO UPDATE SET \\\"passwordHash\\\" = EXCLUDED.\\\"passwordHash\\\";
|
||||
INSERT INTO \\\"UserSettings\\\" (\\\"userId\\\", \\\"playbackQuality\\\", \\\"wifiOnly\\\", \\\"offlineEnabled\\\", \\\"maxCacheSizeMb\\\")
|
||||
VALUES ('e2e_test_user_kima', 'original', false, false, 10240)
|
||||
ON CONFLICT (\\\"userId\\\") DO NOTHING;
|
||||
\"
|
||||
"
|
||||
# Write SQL to a temp file inside the container to avoid dollar sign expansion.
|
||||
# The bcrypt hash contains $2b$10$... which bash would mangle if embedded in
|
||||
# a double-quoted string passed to docker exec.
|
||||
docker exec -e "HASH=${HASH}" -e "TEST_USER=${TEST_USER}" "${CONTAINER}" bash -c '
|
||||
# Heredoc is unquoted so ${TEST_USER} and ${HASH} expand (env vars set via -e above).
|
||||
# SQL single quotes are plain literals here -- no shell escaping needed.
|
||||
psql -U kima -d kima <<ENDSQL
|
||||
INSERT INTO "User" (id, username, "passwordHash", role, "onboardingComplete")
|
||||
VALUES ('\''e2e_test_user_kima'\'', '\''${TEST_USER}'\'', '\''${HASH}'\'', '\''admin'\'', true)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET "passwordHash" = EXCLUDED."passwordHash",
|
||||
role = EXCLUDED.role;
|
||||
INSERT INTO "UserSettings" ("userId", "playbackQuality", "wifiOnly", "offlineEnabled", "maxCacheSizeMb")
|
||||
VALUES ('\''e2e_test_user_kima'\'', '\''original'\'', false, false, 10240)
|
||||
ON CONFLICT ("userId") DO NOTHING;
|
||||
ENDSQL
|
||||
'
|
||||
|
||||
echo "[e2e setup] Test user '${TEST_USER}' ready."
|
||||
echo ""
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the enrichment-cycle Playwright test with before/after host memory monitoring.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/run-enrichment-memory-test.sh [container-name]
|
||||
#
|
||||
# Environment:
|
||||
# KIMA_TEST_USERNAME -- E2E test user (default: kima_e2e)
|
||||
# KIMA_TEST_PASSWORD -- E2E test password (required)
|
||||
# KIMA_UI_BASE_URL -- Base URL (default: http://127.0.0.1:3030)
|
||||
# KIMA_CONTAINER -- Docker container name (default: kima-test)
|
||||
# SUNRECLAIM_LIMIT_MB -- Fail if SUnreclaim grows by more than this (default: 1024)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${KIMA_CONTAINER:-kima-test}"
|
||||
BASE_URL="${KIMA_UI_BASE_URL:-http://127.0.0.1:3030}"
|
||||
SUNRECLAIM_LIMIT="${SUNRECLAIM_LIMIT_MB:-1024}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sunreclaim_mb() {
|
||||
awk '/^SUnreclaim:/ { printf "%d", $2 / 1024 }' /proc/meminfo
|
||||
}
|
||||
|
||||
container_mem_mb() {
|
||||
docker stats --no-stream --format "{{.MemUsage}}" "${CONTAINER}" 2>/dev/null \
|
||||
| awk '{ match($0, /^([0-9.]+)([GMkB]+)/, arr); v=arr[1]; u=arr[2]; if(u~/GiB/) printf "%d", v*1024; else if(u~/MiB/) printf "%d", v; else printf "%d", v/1024 }'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^${CONTAINER}$"; then
|
||||
echo "[error] Container '${CONTAINER}' is not running."
|
||||
echo " Start it first: see MEMORY.md for the build+run command."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMA_TEST_PASSWORD:-}" ]]; then
|
||||
echo "[error] KIMA_TEST_PASSWORD is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[memory-test] Waiting for health check..."
|
||||
timeout 30 bash -c "until curl -sf ${BASE_URL}/api/health > /dev/null; do sleep 2; done"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baseline memory snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUNRECLAIM_BEFORE=$(sunreclaim_mb)
|
||||
CONTAINER_MEM_BEFORE=$(container_mem_mb)
|
||||
|
||||
echo ""
|
||||
echo "=========================================================="
|
||||
echo " Memory baseline"
|
||||
echo " Host SUnreclaim: ${SUNRECLAIM_BEFORE} MB"
|
||||
echo " Container RSS: ${CONTAINER_MEM_BEFORE} MB"
|
||||
echo "=========================================================="
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run the Playwright enrichment-cycle spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRONTEND_DIR="${SCRIPT_DIR}/../frontend"
|
||||
|
||||
echo "[memory-test] Running enrichment-cycle spec..."
|
||||
npx --prefix "${FRONTEND_DIR}" playwright test \
|
||||
tests/e2e/enrichment-cycle.spec.ts \
|
||||
--reporter=list
|
||||
TEST_EXIT=$?
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-test memory snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUNRECLAIM_AFTER=$(sunreclaim_mb)
|
||||
CONTAINER_MEM_AFTER=$(container_mem_mb)
|
||||
SUNRECLAIM_DELTA=$(( SUNRECLAIM_AFTER - SUNRECLAIM_BEFORE ))
|
||||
CONTAINER_MEM_DELTA=$(( CONTAINER_MEM_AFTER - CONTAINER_MEM_BEFORE ))
|
||||
|
||||
echo ""
|
||||
echo "=========================================================="
|
||||
echo " Memory after enrichment cycle"
|
||||
echo " Host SUnreclaim: ${SUNRECLAIM_AFTER} MB (delta: +${SUNRECLAIM_DELTA} MB)"
|
||||
echo " Container RSS: ${CONTAINER_MEM_AFTER} MB (delta: +${CONTAINER_MEM_DELTA} MB)"
|
||||
echo "=========================================================="
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail if slab growth exceeds threshold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if (( SUNRECLAIM_DELTA > SUNRECLAIM_LIMIT )); then
|
||||
echo "[FAIL] Host SUnreclaim grew by ${SUNRECLAIM_DELTA} MB -- exceeds limit of ${SUNRECLAIM_LIMIT} MB."
|
||||
echo " This indicates kernel slab (anon_vma_chain) accumulation from the enrichment pipeline."
|
||||
echo " Check: /proc/slabinfo | grep anon_vma -- if anon_vma_chain is large, suspect"
|
||||
echo " excessive process forks or repeated VMA splits during audio processing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[memory-test] Slab growth within acceptable range (${SUNRECLAIM_DELTA} MB < ${SUNRECLAIM_LIMIT} MB limit)."
|
||||
|
||||
# Propagate Playwright exit code
|
||||
exit "${TEST_EXIT}"
|
||||
Reference in New Issue
Block a user